218 lines
7.3 KiB
Go
218 lines
7.3 KiB
Go
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
|
|
}
|