package auth import ( "encoding/json" "net/http" "strings" "time" "git.mstar.dev/mstar/goutils/other" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" "gorm.io/gorm/clause" "git.mstar.dev/mstar/linstrom/storage-new/dbgen" "git.mstar.dev/mstar/linstrom/storage-new/models" ) // TODO: Check if passkey encryption is viable // Check if encryption for passkey info data is viable to implement // and if we should do it. // Encrypting it would probably require making a custom wrapper struct, // if even possible. Reason being that login completion still requires to update // the data post-creation, including matching on unique and stable elements // of said data // Start the login process via passkey for a given username. // Returns the credential options the passkey needs to sign func (a *Authenticator) StartPasskeyLogin( username string, ) (*protocol.CredentialAssertion, string, error) { if ok, err := a.canUsernameLogin(username); !ok { return nil, "", other.Error("auth", "user may not login", err) } acc, err := dbgen.User.GetByUsername(username) if err != nil { return nil, "", other.Error("auth", "failed to acquire user for login", err) } wrappedAcc := fakeUser{acc} options, session, err := a.webauthn.BeginLogin(&wrappedAcc) if err != nil { return nil, "", other.Error("auth", "failed to initiate passkey login", err) } pkeySession := models.LoginProcessToken{ User: *acc, UserId: acc.ID, ExpiresAt: time.Now().Add(time.Minute * 3), // Abuse name for session storage since token must be a uuid Name: string(other.Must(json.Marshal(session))), } err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{UpdateAll: true}). Omit(dbgen.LoginProcessToken.Token). Create(&pkeySession) if err != nil { return nil, "", other.Error("auth", "failed to create login process token", err) } return options, pkeySession.Token, nil } // Complete a passkey login request // Takes the username logging in as well as the raw request containing the passkey response func (a *Authenticator) CompletePasskeyLogin( username string, sessionId string, response *http.Request, ) (accessToken string, err error) { // Get user in question acc, err := dbgen.User.GetByUsername(username) if err != nil { return "", other.Error("auth", "failed to get user for passkey login completion", err) } // Get latest login token data loginToken, err := dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.Token.Eq(sessionId)). First() if err != nil { return "", other.Error( "auth", "failed to get user's login token for passkey login completion", err, ) } // Check if that token has expired if loginToken.ExpiresAt.Before(time.Now()) { return "", ErrProcessTimeout } var pkeySession webauthn.SessionData err = json.Unmarshal([]byte(loginToken.Name), &pkeySession) if err != nil { return "", other.Error("auth", "failed to unmarshal passkey session for user", err) } // Hand data to webauthn for completion newSession, err := a.webauthn.FinishLogin(&fakeUser{acc}, pkeySession, response) if err != nil { return "", other.Error("auth", "passkey completion failed", err) } // TODO: Utilise clone warning // newSession.Authenticator.CloneWarning jsonSessionId, err := json.Marshal(newSession.ID) if err != nil { return "", other.Error("auth", "failed to marshal session", err) } jsonSession, err := json.Marshal(newSession.ID) if err != nil { return "", other.Error("auth", "failed to marshal session", err) } // Update credentials // WARN: I am not sure if this will work // Using the ID of the passkey session *should* be unique enough to identify the correct one // Of course, even then, there's still the problem of matching as // I can't yet guarantee that the parsed json content for the ID would be the same _, err = dbgen.UserAuthMethod.Where(dbgen.UserAuthMethod.Token.Like("%"+string(jsonSessionId)+"%")). Update(dbgen.UserAuthMethod.Token, jsonSession) if err != nil { return "", other.Error("auth", "failed to update credentials", err) } // And delete the login token _, err = dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.UserId.Eq(acc.ID)). Delete(loginToken) if err != nil { return "", other.Error("auth", "failed to delete login process", err) } dbAccessToken := models.AccessToken{ User: *acc, UserId: acc.ID, ExpiresAt: calcAccessExpirationTimestamp(), } err = dbgen.AccessToken.Omit(dbgen.AccessToken.Token).Create(&dbAccessToken) if err != nil { return "", other.Error("auth", "failed to generate access token", err) } return dbAccessToken.Token, nil } // Start the process of registrating a passkey to an account func (a *Authenticator) StartPasskeyRegistration( username string, passkeyName string, ) (*protocol.CredentialCreation, string, error) { if ok, err := a.canUsernameLogin(username); !ok { return nil, "", other.Error("auth", "user may not login", err) } acc, err := dbgen.User.GetByUsername(username) if err != nil { return nil, "", other.Error("auth", "failed to acquire user for login", err) } wrappedAcc := fakeUser{acc} options, session, err := a.webauthn.BeginRegistration(&wrappedAcc) jsonSession, err := json.Marshal(session) if err != nil { return nil, "", other.Error("auth", "failed to marshal session to json", err) } pkeySession := models.LoginProcessToken{ User: *acc, UserId: acc.ID, ExpiresAt: time.Now().Add(time.Minute * 3), // Abuse name for storing session and passkey name since token must be a uuid Name: passkeyName + "---" + string(jsonSession), } err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{UpdateAll: true}). Omit(dbgen.LoginProcessToken.Token). Create(&pkeySession) if err != nil { return nil, "", other.Error("auth", "failed to create login process token", err) } return options, pkeySession.Token, nil } func (a *Authenticator) CompletePasskeyRegistration( username string, sessionId string, response *http.Request, ) error { // Get latest login token data loginToken, err := dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.Token.Eq(sessionId)). First() if err != nil { return other.Error( "auth", "failed to get user's login token for passkey login completion", err, ) } // Check if that token has expired if loginToken.ExpiresAt.Before(time.Now()) { return ErrProcessTimeout } var pkeySession webauthn.SessionData passkeyName, jsonSession, found := strings.Cut(loginToken.Name, "---") if !found { return ErrInvalidPasskeyRegistrationData } err = json.Unmarshal([]byte(jsonSession), &pkeySession) if err != nil { return other.Error("auth", "failed to unmarshal passkey session for user", err) } wrappedAcc := fakeUser{&loginToken.User} credential, err := a.webauthn.FinishRegistration(&wrappedAcc, pkeySession, response) if err != nil { return other.Error("auth", "failed to complete passkey registration", err) } jsonCredential, err := json.Marshal(credential) if err != nil { return other.Error("auth", "failed to marshal credential to json", err) } authData := models.UserAuthMethod{ User: loginToken.User, UserId: loginToken.UserId, AuthMethod: models.AuthMethodPasskey, Name: passkeyName, Token: jsonCredential, } err = dbgen.UserAuthMethod.Create(&authData) if err != nil { return other.Error("auth", "failed to insert new auth method into db", err) } return nil }