197 lines
5.1 KiB
Go
197 lines
5.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"time"
|
|
|
|
"git.mstar.dev/mstar/goutils/sliceutils"
|
|
"golang.org/x/crypto/argon2"
|
|
|
|
"git.mstar.dev/mstar/linstrom/storage-new"
|
|
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
|
|
"git.mstar.dev/mstar/linstrom/storage-new/models"
|
|
)
|
|
|
|
// Len of salt for passwords in bytes
|
|
const saltLen = 32
|
|
|
|
// Generate a random salt with the given nr of bytes
|
|
func generateSalt(length int) ([]byte, error) {
|
|
salt := make([]byte, length)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return nil, err
|
|
}
|
|
return salt, nil
|
|
}
|
|
|
|
// Hash a password with salt
|
|
func hashPassword(password string) (hash []byte, err error) {
|
|
salt, err := generateSalt(saltLen)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hash = argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
|
|
hash = append(hash, salt...)
|
|
// return bcrypt.GenerateFromPassword([]byte(password), 14)
|
|
return hash, nil
|
|
}
|
|
|
|
// Check wether a password matches a hash
|
|
func comparePassword(password string, hash []byte) bool {
|
|
// Hash is actually hash(password)+salt
|
|
salt := hash[len(hash)-saltLen:]
|
|
|
|
return bytes.Equal(argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32), hash)
|
|
}
|
|
|
|
// Copied and adjusted from: https://bruinsslot.jp/post/golang-crypto/
|
|
|
|
// Encrypt some data using a key
|
|
func Encrypt(key, data []byte) ([]byte, error) {
|
|
key, salt, err := deriveKey(key, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
blockCipher, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(blockCipher)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err = rand.Read(nonce); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ciphertext := gcm.Seal(nonce, nonce, data, nil)
|
|
|
|
ciphertext = append(ciphertext, salt...)
|
|
|
|
return ciphertext, nil
|
|
}
|
|
|
|
// Decrypt some data using a key
|
|
func Decrypt(key, data []byte) ([]byte, error) {
|
|
salt, data := data[len(data)-32:], data[:len(data)-32]
|
|
|
|
key, _, err := deriveKey(key, salt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
blockCipher, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(blockCipher)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():]
|
|
|
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return plaintext, nil
|
|
}
|
|
|
|
// Derive a key from a password and optionally salt.
|
|
// Returns the hash and salt used
|
|
func deriveKey(password, salt []byte) ([]byte, []byte, error) {
|
|
if salt == nil {
|
|
salt = make([]byte, 32)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
|
|
key := argon2.IDKey(password, salt, 1, 64*1024, 4, 32)
|
|
return key, salt, nil
|
|
}
|
|
|
|
// Calculate the expiration timestamp from the call of the function
|
|
func calcAccessExpirationTimestamp() time.Time {
|
|
// For now, the default expiration is one month after creation
|
|
// though "never" might also be a good option
|
|
return time.Now().Add(time.Hour * 24 * 30)
|
|
}
|
|
|
|
// Convert a list of authentication methods into a [LoginNextState] bitflag.
|
|
// [isStart] determines whether to allow authentication methods that start
|
|
// a login process or complete one
|
|
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 },
|
|
)
|
|
}
|
|
|
|
// Translate one [models.AuthenticationMethodType] to one [LoginNextState]
|
|
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.GetByUsername(username)
|
|
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
|
|
}
|