linstrom/auth-new/login.go
2025-03-31 08:07:16 +02:00

186 lines
5.6 KiB
Go

package auth
import (
"time"
"git.mstar.dev/mstar/goutils/other"
"git.mstar.dev/mstar/goutils/sliceutils"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"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/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) {
if ok, err := a.canUsernameLogin(username); !ok {
return LoginNextFailure, "", other.Error("auth", "user may not login", err)
}
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: calcAccessExpirationTimestamp(),
Token: uuid.NewString(),
}
err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{UpdateAll: true}).
Create(&loginToken)
if err != nil {
return LoginNextFailure, "", other.Error(
"auth",
"failed to create login process token",
err,
)
}
return nextStates, loginToken.Token, nil
}
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
}
// TODO: Check roles too
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
}