Password login & registration Passkey login May not be functional yet
This commit is contained in:
parent
b84461d0e7
commit
a9af73b557
3 changed files with 159 additions and 80 deletions
|
@ -9,15 +9,96 @@ package auth
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/goutils/sliceutils"
|
||||||
"github.com/go-webauthn/webauthn/webauthn"
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage-new"
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage-new/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Authenticator struct {
|
type Authenticator struct {
|
||||||
webauthn *webauthn.WebAuthn
|
webauthn *webauthn.WebAuthn
|
||||||
}
|
}
|
||||||
|
type LoginNextState uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
LoginNextFailure LoginNextState = 0 // Login failed (default state)
|
||||||
|
LoginNextSucess LoginNextState = 1 << iota // Login suceeded
|
||||||
|
LoginUnknown // Unknown login method type, should result in failure
|
||||||
|
LoginNext2FaTotp // Login requires a totp token next as 2fa response
|
||||||
|
LoginNext2FaPasskey // Login requires a passkey token next as 2fa response
|
||||||
|
LoginNext2FaMail // Login requires an email token next as 2fa response
|
||||||
|
LoginStartPassword // Login starts with a password
|
||||||
|
LoginStartPasskey // Login starts with a passkey
|
||||||
|
)
|
||||||
|
|
||||||
func calcAccessExpirationTimestamp() time.Time {
|
func calcAccessExpirationTimestamp() time.Time {
|
||||||
// For now, the default expiration is one month after creation
|
// For now, the default expiration is one month after creation
|
||||||
// though "never" might also be a good option
|
// though "never" might also be a good option
|
||||||
return time.Now().Add(time.Hour * 24 * 30)
|
return time.Now().Add(time.Hour * 24 * 30)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ConvertNewStorageAuthMethodsToLoginState(
|
||||||
|
methods []models.AuthenticationMethodType,
|
||||||
|
isStart bool,
|
||||||
|
) LoginNextState {
|
||||||
|
translatedMethods := sliceutils.Map(methods, oneStorageAuthToLoginState)
|
||||||
|
// Filter out only the valid methods for the current request
|
||||||
|
valids := sliceutils.Filter(translatedMethods, func(t LoginNextState) bool {
|
||||||
|
if isStart {
|
||||||
|
return t == LoginStartPasskey || t == LoginStartPassword
|
||||||
|
} else {
|
||||||
|
return t == LoginNext2FaTotp || t == LoginNext2FaPasskey || t == LoginNext2FaMail
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// And then compact them down into one bit flag
|
||||||
|
return sliceutils.Compact(
|
||||||
|
valids,
|
||||||
|
func(acc, next LoginNextState) LoginNextState { return acc | next },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func oneStorageAuthToLoginState(in models.AuthenticationMethodType) LoginNextState {
|
||||||
|
switch in {
|
||||||
|
case models.AuthMethodGAuth:
|
||||||
|
return LoginNext2FaTotp
|
||||||
|
case models.AuthMethodMail:
|
||||||
|
return LoginNext2FaMail
|
||||||
|
case models.AuthMethodPasskey:
|
||||||
|
return LoginStartPasskey
|
||||||
|
case models.AuthMethodPasskey2fa:
|
||||||
|
return LoginNext2FaPasskey
|
||||||
|
case models.AuthMethodPassword:
|
||||||
|
return LoginStartPassword
|
||||||
|
default:
|
||||||
|
return LoginUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check whether a given username can log in.
|
||||||
|
// It only provides a yes/no answer and an error in case some check failed
|
||||||
|
// due to unforseen circumstances. Though the error will always be ErrCantLogin
|
||||||
|
// in case the known information (excluding database or other failures) prevents
|
||||||
|
// a login
|
||||||
|
//
|
||||||
|
// TODO: Decide whether to include the reason for disallowed login
|
||||||
|
func (a *Authenticator) canUsernameLogin(username string) (bool, error) {
|
||||||
|
acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !acc.FinishedRegistration {
|
||||||
|
return false, ErrCantLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
finalRole := storage.CollapseRolesIntoOne(
|
||||||
|
sliceutils.Map(acc.Roles, func(t models.UserToRole) models.Role {
|
||||||
|
return t.Role
|
||||||
|
})...)
|
||||||
|
if finalRole.CanLogin != nil && !*finalRole.CanLogin {
|
||||||
|
return false, ErrCantLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
|
@ -14,34 +14,42 @@ import (
|
||||||
"git.mstar.dev/mstar/linstrom/storage-new/models"
|
"git.mstar.dev/mstar/linstrom/storage-new/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *Authenticator) StartPasskeyLogin(username string) (*protocol.CredentialAssertion, error) {
|
// 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, uint64, error) {
|
||||||
if ok, err := a.canUsernameLogin(username); !ok {
|
if ok, err := a.canUsernameLogin(username); !ok {
|
||||||
return nil, other.Error("auth", "user may not login", err)
|
return nil, 0, other.Error("auth", "user may not login", err)
|
||||||
}
|
}
|
||||||
acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First()
|
acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, 0, other.Error("auth", "failed to acquire user for login", err)
|
||||||
}
|
}
|
||||||
wrappedAcc := fakeUser{acc}
|
wrappedAcc := fakeUser{acc}
|
||||||
options, session, err := a.webauthn.BeginLogin(&wrappedAcc)
|
options, session, err := a.webauthn.BeginLogin(&wrappedAcc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, 0, other.Error("auth", "failed to initiate passkey login", err)
|
||||||
}
|
}
|
||||||
pkeySession := models.LoginProcessToken{
|
pkeySession := models.LoginProcessToken{
|
||||||
User: *acc,
|
User: *acc,
|
||||||
UserId: acc.ID,
|
UserId: acc.ID,
|
||||||
ExpiresAt: time.Now().Add(time.Minute * 3),
|
ExpiresAt: time.Now().Add(time.Minute * 3),
|
||||||
Token: string(other.Must(json.Marshal(session))),
|
Token: string(other.Must(json.Marshal(session))),
|
||||||
|
Name: "",
|
||||||
}
|
}
|
||||||
err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{UpdateAll: true}).Create(&pkeySession)
|
err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{UpdateAll: true}).Create(&pkeySession)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, 0, other.Error("auth", "failed to create login process token", err)
|
||||||
}
|
}
|
||||||
return options, nil
|
return options, pkeySession.ID, 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(
|
func (a *Authenticator) CompletePasskeyLogin(
|
||||||
username string,
|
username string,
|
||||||
|
sessionId uint64,
|
||||||
response *http.Request,
|
response *http.Request,
|
||||||
) (accessToken string, err error) {
|
) (accessToken string, err error) {
|
||||||
// Get user in question
|
// Get user in question
|
||||||
|
@ -50,7 +58,7 @@ func (a *Authenticator) CompletePasskeyLogin(
|
||||||
return "", other.Error("auth", "failed to get user for passkey login completion", err)
|
return "", other.Error("auth", "failed to get user for passkey login completion", err)
|
||||||
}
|
}
|
||||||
// Get latest login token data
|
// Get latest login token data
|
||||||
loginToken, err := dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.UserId.Eq(acc.ID)).
|
loginToken, err := dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.ID.Eq(sessionId)).
|
||||||
First()
|
First()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", other.Error(
|
return "", other.Error(
|
||||||
|
@ -70,18 +78,33 @@ func (a *Authenticator) CompletePasskeyLogin(
|
||||||
}
|
}
|
||||||
// Hand data to webauthn for completion
|
// Hand data to webauthn for completion
|
||||||
newSession, err := a.webauthn.FinishLogin(&fakeUser{acc}, pkeySession, response)
|
newSession, err := a.webauthn.FinishLogin(&fakeUser{acc}, pkeySession, response)
|
||||||
jsonSession, err := json.Marshal(newSession)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", other.Error("auth", "passkey completion failed", err)
|
||||||
|
}
|
||||||
|
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
|
// Update credentials
|
||||||
_, err = dbgen.UserAuthMethod.Where(dbgen.UserAuthMethod.Token.Like("%"+string(jsonSession)+"%")).
|
// 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)
|
Update(dbgen.UserAuthMethod.Token, jsonSession)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", other.Error("auth", "failed to update credentials", err)
|
||||||
}
|
}
|
||||||
// And delete the login token
|
// And delete the login token
|
||||||
dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.UserId.Eq(acc.ID)).Delete(loginToken)
|
_, 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{
|
dbAccessToken := models.AccessToken{
|
||||||
User: *acc,
|
User: *acc,
|
||||||
UserId: acc.ID,
|
UserId: acc.ID,
|
||||||
|
@ -89,13 +112,16 @@ func (a *Authenticator) CompletePasskeyLogin(
|
||||||
}
|
}
|
||||||
err = dbgen.AccessToken.Omit(dbgen.AccessToken.Token).Create(&dbAccessToken)
|
err = dbgen.AccessToken.Omit(dbgen.AccessToken.Token).Create(&dbAccessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", other.Error("auth", "failed to generate access token", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return dbAccessToken.Token, nil
|
return dbAccessToken.Token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Authenticator) StartPasskeyRegistration(username string) error {
|
// Start the process of registrating a passkey to an account
|
||||||
|
func (a *Authenticator) StartPasskeyRegistration(
|
||||||
|
username string,
|
||||||
|
) (*protocol.CredentialAssertion, error) {
|
||||||
// p.l.Infof("begin registration")
|
// p.l.Infof("begin registration")
|
||||||
//
|
//
|
||||||
// // can we actually do not use the username at all?
|
// // can we actually do not use the username at all?
|
||||||
|
|
|
@ -10,61 +10,10 @@ import (
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
"git.mstar.dev/mstar/linstrom/storage-new"
|
|
||||||
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
|
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
|
||||||
"git.mstar.dev/mstar/linstrom/storage-new/models"
|
"git.mstar.dev/mstar/linstrom/storage-new/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LoginNextState uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
LoginNextFailure LoginNextState = 0 // Login failed (default state)
|
|
||||||
LoginNextSucess LoginNextState = 1 << iota // Login suceeded
|
|
||||||
LoginUnknown // Unknown login method type, should result in failure
|
|
||||||
LoginNext2FaTotp // Login requires a totp token next as 2fa response
|
|
||||||
LoginNext2FaPasskey // Login requires a passkey token next as 2fa response
|
|
||||||
LoginNext2FaMail // Login requires an email token next as 2fa response
|
|
||||||
LoginStartPassword // Login starts with a password
|
|
||||||
LoginStartPasskey // Login starts with a passkey
|
|
||||||
)
|
|
||||||
|
|
||||||
func ConvertNewStorageAuthMethodsToLoginState(
|
|
||||||
methods []models.AuthenticationMethodType,
|
|
||||||
isStart bool,
|
|
||||||
) LoginNextState {
|
|
||||||
translatedMethods := sliceutils.Map(methods, oneStorageAuthToLoginState)
|
|
||||||
// Filter out only the valid methods for the current request
|
|
||||||
valids := sliceutils.Filter(translatedMethods, func(t LoginNextState) bool {
|
|
||||||
if isStart {
|
|
||||||
return t == LoginStartPasskey || t == LoginStartPassword
|
|
||||||
} else {
|
|
||||||
return t == LoginNext2FaTotp || t == LoginNext2FaPasskey || t == LoginNext2FaMail
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// And then compact them down into one bit flag
|
|
||||||
return sliceutils.Compact(
|
|
||||||
valids,
|
|
||||||
func(acc, next LoginNextState) LoginNextState { return acc | next },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func oneStorageAuthToLoginState(in models.AuthenticationMethodType) LoginNextState {
|
|
||||||
switch in {
|
|
||||||
case models.AuthMethodGAuth:
|
|
||||||
return LoginNext2FaTotp
|
|
||||||
case models.AuthMethodMail:
|
|
||||||
return LoginNext2FaMail
|
|
||||||
case models.AuthMethodPasskey:
|
|
||||||
return LoginStartPasskey
|
|
||||||
case models.AuthMethodPasskey2fa:
|
|
||||||
return LoginNext2FaPasskey
|
|
||||||
case models.AuthMethodPassword:
|
|
||||||
return LoginStartPassword
|
|
||||||
default:
|
|
||||||
return LoginUnknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func hashPassword(password string) ([]byte, error) {
|
func hashPassword(password string) ([]byte, error) {
|
||||||
return bcrypt.GenerateFromPassword([]byte(password), 14)
|
return bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||||
}
|
}
|
||||||
|
@ -77,7 +26,7 @@ func comparePassword(password string, hash []byte) bool {
|
||||||
// Returns the next state, a token corresponding to that state and error
|
// Returns the next state, a token corresponding to that state and error
|
||||||
// Token will be empty on failure, error describes the reason for the
|
// Token will be empty on failure, error describes the reason for the
|
||||||
// failure
|
// failure
|
||||||
func (a *Authenticator) StartPasswordLogin(
|
func (a *Authenticator) PerformPasswordLogin(
|
||||||
username string,
|
username string,
|
||||||
password string,
|
password string,
|
||||||
) (nextState LoginNextState, token string, err error) {
|
) (nextState LoginNextState, token string, err error) {
|
||||||
|
@ -164,23 +113,46 @@ func (a *Authenticator) StartPasswordLogin(
|
||||||
return nextStates, loginToken.Token, nil
|
return nextStates, loginToken.Token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Authenticator) canUsernameLogin(username string) (bool, error) {
|
// Register a password to an account
|
||||||
|
// There is no check for whether that is allowed from the active roles.
|
||||||
|
// If the given username already has a password set, it gets updated to the new one.
|
||||||
|
// If there is no password set yet (i.e. during account registration or passkey only so far)
|
||||||
|
// it creates the password link
|
||||||
|
func (a *Authenticator) PerformPasswordRegister(username, password string) error {
|
||||||
acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First()
|
acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return other.Error("auth", "failed to get user to add a password to", err)
|
||||||
}
|
}
|
||||||
if !acc.FinishedRegistration {
|
passwordHash, err := hashPassword(password)
|
||||||
return false, ErrCantLogin
|
if err != nil {
|
||||||
|
return other.Error("auth", "failed to hash password", err)
|
||||||
}
|
}
|
||||||
|
passwordMethods := sliceutils.Filter(
|
||||||
// TODO: Check roles too
|
acc.AuthMethods,
|
||||||
finalRole := storage.CollapseRolesIntoOne(
|
func(t models.UserAuthMethod) bool { return t.AuthMethod == models.AuthMethodPassword },
|
||||||
sliceutils.Map(acc.Roles, func(t models.UserToRole) models.Role {
|
)
|
||||||
return t.Role
|
if len(passwordMethods) > 0 {
|
||||||
})...)
|
// TODO: Decide whether PerformPasswordRegister can also update existing passwords
|
||||||
if finalRole.CanLogin != nil && !*finalRole.CanLogin {
|
// Imo yes, since an account can have at most one password set and having a separate method for updating
|
||||||
return false, ErrCantLogin
|
// would increase complexity
|
||||||
|
// For now, do perform an update
|
||||||
|
dbPass := passwordMethods[0]
|
||||||
|
_, err = dbgen.UserAuthMethod.Where(dbgen.UserAuthMethod.ID.Eq(dbPass.ID)).
|
||||||
|
Update(dbgen.UserAuthMethod.Token, passwordHash)
|
||||||
|
if err != nil {
|
||||||
|
return other.Error("auth", "failed to update password", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dbPass := models.UserAuthMethod{
|
||||||
|
Token: passwordHash,
|
||||||
|
AuthMethod: models.AuthMethodPassword,
|
||||||
|
User: *acc,
|
||||||
|
UserId: acc.ID,
|
||||||
|
}
|
||||||
|
err = dbgen.UserAuthMethod.Omit(dbgen.UserAuthMethod.ID).Create(&dbPass)
|
||||||
|
if err != nil {
|
||||||
|
return other.Error("auth", "failed to insert password", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
return true, nil
|
|
||||||
}
|
}
|
Loading…
Reference in a new issue