feat: complete registration invitation and auth redesign

This commit is contained in:
richjohnrambo
2026-08-19 00:30:11 +08:00
parent e063e7879e
commit 219ecadf4c
47 changed files with 9014 additions and 224 deletions
+154 -6
View File
@@ -34,13 +34,27 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
// Find authenticator for the requested scheme.
authhdl := store.Store.GetLogicalAuthHandler(msg.Acc.Scheme)
if authhdl == nil {
if authhdl == nil || msg.Acc.Scheme != "basic" {
// New accounts must have an authentication scheme
s.queueOut(ErrMalformed(msg.Id, "", msg.Timestamp))
logs.Warn.Println("create user: unknown auth handler, sid=", s.sid)
return
}
account, phone, phoneRegistration, err := resolveRegistrationAccount(msg.Acc.Username, msg.Acc.Phone)
if err != nil || !registrationSecretMatches(msg.Acc.Secret, account) {
logs.Warn.Println("create user: invalid registration account fields, sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
map[string]any{"what": "account", "reason": "REGISTRATION_ACCOUNT_INVALID"}))
return
}
inviteCode := strings.TrimSpace(msg.Acc.InviteCode)
if msg.Acc.InviteCode != inviteCode || (inviteCode != "" && !isValidIMInviteCode(inviteCode)) {
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
map[string]any{"what": "invite_code", "reason": "INVITE_CODE_INVALID"}))
return
}
// Check if login is unique and compliance with the policy (not too long or too short).
authCtx := auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr}
if ok, err := authhdl.IsUnique(authCtx, msg.Acc.Secret); !ok {
@@ -52,6 +66,8 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
var user types.User
user.TenantID = s.tenantID
user.Username = account
user.Phone = phone
var private any
// If account state is being assigned, make sure the sender is a root user.
@@ -85,9 +101,15 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
// consequently cannot check presence of required credentials. Must do that later.
creds := normalizeCredentials(msg.Acc.Cred, true)
var registrationPhone string
var registrationTelCredential bool
for i := range creds {
cr := &creds[i]
vld := store.Store.GetValidator(cr.Method)
if vld == nil || !vld.IsInitialized() {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"what": cr.Method}))
return
}
normalized, err := vld.PreCheck(cr.Value, cr.Params)
if err != nil {
logs.Warn.Println("create user: failed credential pre-check", cr, err, "sid=", s.sid)
@@ -105,15 +127,40 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
return
}
registrationPhone = phone
registrationTelCredential = true
}
}
if registrationPhone == "" && store.Verifications != nil {
logs.Warn.Println("create user: verified phone is required, sid=", s.sid)
if !phoneRegistration && registrationTelCredential {
logs.Warn.Println("create user: username registration cannot attach a phone credential, sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}}))
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_NOT_SELECTED"}))
return
}
user.Phone = registrationPhone
if phoneRegistration && registrationTelCredential && registrationPhone != phone {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_MISMATCH"}))
return
}
smsEnabled := registrationSMSEnabled(time.Now().UTC())
if phoneRegistration && smsEnabled && !registrationTelCredential {
logs.Warn.Println("create user: verified phone is required while registration SMS is enabled, sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_VERIFICATION_REQUIRED"}))
return
}
inviteRequired := !phoneRegistration || (!smsEnabled && !registrationTelCredential)
if inviteRequired && inviteCode == "" {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"what": "invite_code", "reason": "INVITE_CODE_REQUIRED"}))
return
}
if inviteCode != "" {
if err := validateRegistrationInvite(s.tenantID, inviteCode, time.Now().UTC()); err != nil {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"what": "invite_code", "reason": registrationInviteReason(err)}))
return
}
}
// Assign default access values in case the acc creator has not provided them
user.Access.Auth = getDefaultAccess(types.TopicCatP2P, true, false) |
@@ -155,7 +202,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
}
// Add authentication record.
rec, err := authhdl.AddRecord(authCtx,
rec, err = authhdl.AddRecord(authCtx,
&auth.Rec{TenantID: s.tenantID, Uid: user.Uid()}, msg.Acc.Secret)
if err != nil {
logs.Warn.Println("create user: add auth record failed", err, "sid=", s.sid)
@@ -201,6 +248,34 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
}
return
}
if registrationTelCredential && !slices.Contains(validated, "tel") {
logs.Warn.Println("create user: phone credential was not validated, sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_VERIFICATION_REQUIRED"}))
if err = store.Users.Delete(s.tenantID, user.Uid(), true); err != nil {
logs.Warn.Println("create user: failed to delete incomplete user record", err, "sid=", s.sid)
}
return
}
if inviteCode != "" {
requestID := strings.TrimSpace(msg.Id)
if requestID == "" {
requestID = "acc:" + s.sid
}
if len(requestID) > 64 {
requestID = requestID[:64]
}
if err = store.RegistrationInvites.RecordUse(s.tenantID, inviteCode, user.Uid(), requestID, time.Now().UTC()); err != nil {
logs.Warn.Println("create user: record invite use failed", err, "sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"what": "invite_code", "reason": registrationInviteReason(err)}))
if deleteErr := store.Users.Delete(s.tenantID, user.Uid(), true); deleteErr != nil {
logs.Warn.Println("create user: failed to delete incomplete user record", deleteErr, "sid=", s.sid)
}
return
}
dispatchIMBusinessEvents(s.tenantID)
}
if msg.Extra != nil && len(msg.Extra.Attachments) > 0 {
if err := store.Files.LinkAttachments(s.tenantID, user.Uid().UserId(), types.ZeroUid, msg.Extra.Attachments); err != nil {
@@ -240,6 +315,79 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
pluginAccount(&user, plgActCreate)
}
func resolveRegistrationAccount(username, phone string) (account, canonicalPhone string, phoneRegistration bool, err error) {
if phone != "" {
if len(phone) != 11 || !asciiRegistrationDigits(phone) {
return "", "", false, types.ErrPolicy
}
canonicalPhone, err = normalizeIMPhone(phone, "CN")
if err != nil {
return "", "", false, err
}
return canonicalPhone, canonicalPhone, true, nil
}
if username == "" || len(username) > 10 {
return "", "", false, types.ErrPolicy
}
for i := range username {
if (username[i] < 'a' || username[i] > 'z') && (username[i] < '0' || username[i] > '9') {
return "", "", false, types.ErrPolicy
}
}
return username, "", false, nil
}
func asciiRegistrationDigits(value string) bool {
for i := range value {
if value[i] < '0' || value[i] > '9' {
return false
}
}
return value != ""
}
func registrationSecretMatches(secret []byte, account string) bool {
separator := strings.IndexByte(string(secret), ':')
return separator > 0 && string(secret[:separator]) == account
}
func validateRegistrationInvite(tenantID types.TenantID, code string, now time.Time) error {
if store.RegistrationInvites == nil {
return types.ErrInternal
}
invite, err := store.RegistrationInvites.GetByCode(tenantID, code)
if err != nil {
return err
}
if invite == nil {
return types.ErrInviteInvalid
}
switch invite.Status {
case types.RegistrationInviteRevoked:
return types.ErrInviteRevoked
case types.RegistrationInviteExpired:
return types.ErrInviteExpired
case types.RegistrationInviteActive:
if !now.Before(invite.ExpiresAt) {
return types.ErrInviteExpired
}
return nil
default:
return types.ErrInviteInvalid
}
}
func registrationInviteReason(err error) string {
switch err {
case types.ErrInviteExpired:
return "INVITE_CODE_EXPIRED"
case types.ErrInviteRevoked:
return "INVITE_CODE_REVOKED"
default:
return "INVITE_CODE_INVALID"
}
}
// Process update to an account:
// * Authentication update, i.e. login/password change
// * Credentials update