Work on new auth framework based on new storage system
Some checks are pending
/ test (push) Waiting to run

This commit is contained in:
Melody Becker 2025-03-28 16:17:08 +01:00
parent 66d6299295
commit 9496ba0cc6
Signed by: mstar
SSH key fingerprint: SHA256:9VAo09aaVNTWKzPW7Hq2LW+ox9OdwmTSHRoD4mlz1yI
5 changed files with 264 additions and 0 deletions

9
auth-new/auth.go Normal file
View file

@ -0,0 +1,9 @@
package auth
import (
"github.com/go-webauthn/webauthn/webauthn"
)
type Authenticator struct {
webauthn *webauthn.WebAuthn
}

11
auth-new/errors.go Normal file
View file

@ -0,0 +1,11 @@
package auth
import "errors"
var (
// The provided authentication method is not known to the server
ErrUnknownAuthMethod = errors.New("unknown authentication method")
// The user hasn't setup the provided authentication method
ErrUnsupportedAuthMethod = errors.New("authentication method not supported for this user")
ErrInvalidCombination = errors.New("invalid account and token combination")
)

67
auth-new/fakeUser.go Normal file
View file

@ -0,0 +1,67 @@
package auth
import (
"encoding/json"
"git.mstar.dev/mstar/goutils/sliceutils"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/rs/zerolog/log"
"git.mstar.dev/mstar/linstrom/storage-new/models"
)
type fakeUser struct {
actualUser *models.User
}
// Ensure that fakeUser always implements webauthn.User
var _ webauthn.User = &fakeUser{}
// WebAuthnID provides the user handle of the user account. A user handle is an opaque byte sequence with a maximum
// size of 64 bytes, and is not meant to be displayed to the user.
//
// To ensure secure operation, authentication and authorization decisions MUST be made on the basis of this id
// member, not the displayName nor name members. See Section 6.1 of [RFC8266].
//
// It's recommended this value is completely random and uses the entire 64 bytes.
//
// Specification: §5.4.3. User Account Parameters for Credential Generation (https://w3c.github.io/webauthn/#dom-publickeycredentialuserentity-id)
func (fakeuser *fakeUser) WebAuthnID() []byte {
return fakeuser.actualUser.PasskeyId
}
// WebAuthnName provides the name attribute of the user account during registration and is a human-palatable name for the user
// account, intended only for display. For example, "Alex Müller" or "田中倫". The Relying Party SHOULD let the user
// choose this, and SHOULD NOT restrict the choice more than necessary.
//
// Specification: §5.4.3. User Account Parameters for Credential Generation (https://w3c.github.io/webauthn/#dictdef-publickeycredentialuserentity)
func (fakeuser *fakeUser) WebAuthnName() string {
return fakeuser.actualUser.DisplayName
}
// WebAuthnDisplayName provides the name attribute of the user account during registration and is a human-palatable
// name for the user account, intended only for display. For example, "Alex Müller" or "田中倫". The Relying Party
// SHOULD let the user choose this, and SHOULD NOT restrict the choice more than necessary.
//
// Specification: §5.4.3. User Account Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialuserentity-displayname)
func (fakeuser *fakeUser) WebAuthnDisplayName() string {
return fakeuser.actualUser.DisplayName
}
// WebAuthnCredentials provides the list of Credential objects owned by the user.
func (fakeuser *fakeUser) WebAuthnCredentials() []webauthn.Credential {
// Basically just convert the relevant entries from the user to a credential
return sliceutils.Map(sliceutils.Filter(
fakeuser.actualUser.AuthMethods,
func(t models.UserAuthMethod) bool {
return t.AuthMethod == models.AuthMethodPasskey ||
t.AuthMethod == models.AuthMethodPasskey2fa
}), func(t models.UserAuthMethod) webauthn.Credential {
var c webauthn.Credential
if err := json.Unmarshal(t.Token, &c); err != nil {
// TODO: Is there any better way to handle the error here?
log.Error().Err(err).Msg("Failed to unmarshal webauthn credential")
}
return c
})
}

161
auth-new/login.go Normal file
View file

@ -0,0 +1,161 @@
package auth
import (
"time"
"git.mstar.dev/mstar/goutils/other"
"git.mstar.dev/mstar/goutils/sliceutils"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"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) {
return bcrypt.GenerateFromPassword([]byte(password), 14)
}
func comparePassword(password string, hash []byte) bool {
return bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil
}
// Start a login process with a username (NOT account ID) and password
// Returns the next state, a token corresponding to that state and error
// Token will be empty on failure, error describes the reason for the
// failure
func (a *Authenticator) StartPasswordLogin(
username string,
password string,
) (nextState LoginNextState, token string, err error) {
var acc *models.User
acc, err = dbgen.User.Where(dbgen.User.Username.Eq(username)).First()
switch err {
case nil:
break
case gorm.ErrRecordNotFound:
return LoginNextFailure, "", ErrInvalidCombination
}
var method *models.UserAuthMethod
for _, authMethod := range acc.AuthMethods {
if oneStorageAuthToLoginState(authMethod.AuthMethod) == LoginStartPassword {
method = &authMethod
break
}
}
if method == nil {
return LoginNextFailure, "", ErrUnsupportedAuthMethod
}
if !comparePassword(password, method.Token) {
return LoginNextFailure, "", ErrInvalidCombination
}
nextStates := ConvertNewStorageAuthMethodsToLoginState(
sliceutils.Map(
acc.AuthMethods,
func(t models.UserAuthMethod) models.AuthenticationMethodType {
return t.AuthMethod
},
),
false,
)
// Catch unknown login methods
if nextStates&LoginUnknown == LoginUnknown {
return LoginNextFailure, "", ErrUnknownAuthMethod
}
if nextStates == LoginNextFailure {
// Login ok, no 2fa needed
// Create a new token. Don't generate the token itself, let postgres handle that
token := models.AccessToken{
User: *acc,
UserId: acc.ID,
ExpiresAt: time.Now().Add(time.Hour * 24 * 365),
}
err = dbgen.AccessToken.
// technically, the chance of a conflict is so incredibly low that it should never ever occur
// since both the username and a randomly generated uuid would need to be created the same. Twice
// But, just in case, do this
Clauses(clause.OnConflict{DoNothing: true}).
Omit(dbgen.AccessToken.Token).
Create(&token)
if err != nil {
return LoginNextFailure, "", other.Error(
"auth",
"failed to create new access token",
err,
)
}
return LoginNextSucess, token.Token, nil
}
// TODO: Generate login process token
loginToken := models.LoginProcessToken{
User: *acc,
UserId: acc.ID,
ExpiresAt: time.Now().Add(time.Minute * 5),
}
err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{DoNothing: true}).
Omit(dbgen.LoginProcessToken.Token).
Create(&loginToken)
if err != nil {
return LoginNextFailure, "", other.Error(
"auth",
"failed to create login process token",
err,
)
}
return nextStates, loginToken.Token, nil
}

16
auth-new/passkey.go Normal file
View file

@ -0,0 +1,16 @@
package auth
import "git.mstar.dev/mstar/linstrom/storage-new/dbgen"
func (a *Authenticator) StartPasskeyLogin(username string) error {
acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First()
if err != nil {
return err
}
_ = acc
panic("Not implemented") // TODO: Implement me
}
func (a *Authenticator) CompletePasskeyLogin(username string) error {
panic("Not implemented") // TODO: Implement me
}