Cloudreve/service/user/passkey.go

289 lines
8.6 KiB
Go

package user
import (
"context"
"encoding/base64"
"encoding/gob"
"errors"
"fmt"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/gofrs/uuid"
"github.com/samber/lo"
"strconv"
"strings"
)
func init() {
gob.Register(webauthn.SessionData{})
}
type authnUser struct {
hasher hashid.Encoder
u *ent.User
credentials []*ent.Passkey
}
func (a *authnUser) WebAuthnID() []byte {
return []byte(hashid.EncodeUserID(a.hasher, a.u.ID))
}
func (a *authnUser) WebAuthnName() string {
return a.u.Email
}
func (a *authnUser) WebAuthnDisplayName() string {
return a.u.Nick
}
func (a *authnUser) WebAuthnCredentials() []webauthn.Credential {
if a.credentials == nil {
return nil
}
return lo.Map(a.credentials, func(item *ent.Passkey, index int) webauthn.Credential {
return *item.Credential
})
}
const (
authnSessionKey = "authn_session_"
)
func PreparePasskeyLogin(c *gin.Context) (*PreparePasskeyLoginResponse, error) {
dep := dependency.FromContext(c)
webAuthn, err := dep.WebAuthn(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to initialize WebAuthn", err)
}
options, sessionData, err := webAuthn.BeginDiscoverableLogin()
if err != nil {
return nil, serializer.NewError(serializer.CodeInitializeAuthn, "Failed to begin registration", err)
}
sessionID := uuid.Must(uuid.NewV4()).String()
if err := dep.KV().Set(fmt.Sprintf("%s%s", authnSessionKey, sessionID), *sessionData, 300); err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to store session data", err)
}
return &PreparePasskeyLoginResponse{
Options: options,
SessionID: sessionID,
}, nil
}
type (
FinishPasskeyLoginParameterCtx struct{}
FinishPasskeyLoginService struct {
Response string `json:"response" binding:"required"`
SessionID string `json:"session_id" binding:"required"`
}
)
func (s *FinishPasskeyLoginService) FinishPasskeyLogin(c *gin.Context) (*ent.User, error) {
dep := dependency.FromContext(c)
kv := dep.KV()
userClient := dep.UserClient()
sessionDataRaw, ok := kv.Get(fmt.Sprintf("%s%s", authnSessionKey, s.SessionID))
if !ok {
return nil, serializer.NewError(serializer.CodeNotFound, "Session not found", nil)
}
_ = kv.Delete(authnSessionKey, s.Response)
webAuthn, err := dep.WebAuthn(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to initialize WebAuthn", err)
}
sessionData := sessionDataRaw.(webauthn.SessionData)
pcc, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(s.Response))
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Failed to parse request", err)
}
var loginedUser *ent.User
discoverUserHandle := func(rawID, userHandle []byte) (user webauthn.User, err error) {
uid, err := dep.HashIDEncoder().Decode(string(userHandle), hashid.UserID)
if err != nil {
return nil, err
}
ctx := context.WithValue(c, inventory.LoadUserPasskey{}, true)
ctx = context.WithValue(ctx, inventory.LoadUserGroup{}, true)
u, err := userClient.GetLoginUserByID(ctx, uid)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user", err)
}
if inventory.IsAnonymousUser(u) {
return nil, errors.New("anonymous user")
}
loginedUser = u
return &authnUser{u: u, hasher: dep.HashIDEncoder(), credentials: u.Edges.Passkey}, nil
}
credential, err := webAuthn.ValidateDiscoverableLogin(discoverUserHandle, sessionData, pcc)
if err != nil {
return nil, serializer.NewError(serializer.CodeWebAuthnCredentialError, "Failed to validate login", err)
}
// Find the credential just used
usedCredentialId := base64.StdEncoding.EncodeToString(credential.ID)
usedCredential, found := lo.Find(loginedUser.Edges.Passkey, func(item *ent.Passkey) bool {
return item.CredentialID == usedCredentialId
})
if !found {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Passkey login passed but credential used is unknown", nil)
}
// Update used at
if err := userClient.MarkPasskeyUsed(c, loginedUser.ID, usedCredential.CredentialID); err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update passkey", err)
}
return loginedUser, nil
}
func PreparePasskeyRegister(c *gin.Context) (*protocol.CredentialCreation, error) {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
u := inventory.UserFromContext(c)
existingKeys, err := userClient.ListPasskeys(c, u.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list passkeys", err)
}
webAuthn, err := dep.WebAuthn(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to initialize WebAuthn", err)
}
authSelect := protocol.AuthenticatorSelection{
RequireResidentKey: protocol.ResidentKeyRequired(),
UserVerification: protocol.VerificationPreferred,
}
options, sessionData, err := webAuthn.BeginRegistration(
&authnUser{u: u, hasher: dep.HashIDEncoder()},
webauthn.WithAuthenticatorSelection(authSelect),
webauthn.WithExclusions(lo.Map(existingKeys, func(item *ent.Passkey, index int) protocol.CredentialDescriptor {
return protocol.CredentialDescriptor{
Type: protocol.PublicKeyCredentialType,
CredentialID: item.Credential.ID,
Transport: item.Credential.Transport,
AttestationType: item.Credential.AttestationType,
}
})),
)
if err != nil {
return nil, serializer.NewError(serializer.CodeInitializeAuthn, "Failed to begin registration", err)
}
if err := dep.KV().Set(fmt.Sprintf("%s%d", authnSessionKey, u.ID), *sessionData, 300); err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to store session data", err)
}
return options, nil
}
type (
FinishPasskeyRegisterParameterCtx struct{}
FinishPasskeyRegisterService struct {
Response string `json:"response" binding:"required"`
Name string `json:"name" binding:"required"`
UA string `json:"ua" binding:"required"`
}
)
func (s *FinishPasskeyRegisterService) FinishPasskeyRegister(c *gin.Context) (*Passkey, error) {
dep := dependency.FromContext(c)
kv := dep.KV()
u := inventory.UserFromContext(c)
sessionDataRaw, ok := kv.Get(fmt.Sprintf("%s%d", authnSessionKey, u.ID))
if !ok {
return nil, serializer.NewError(serializer.CodeNotFound, "Session not found", nil)
}
_ = kv.Delete(authnSessionKey, strconv.Itoa(u.ID))
webAuthn, err := dep.WebAuthn(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to initialize WebAuthn", err)
}
sessionData := sessionDataRaw.(webauthn.SessionData)
pcc, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(s.Response))
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Failed to parse request", err)
}
credential, err := webAuthn.CreateCredential(&authnUser{u: u, hasher: dep.HashIDEncoder()}, sessionData, pcc)
if err != nil {
return nil, serializer.NewError(serializer.CodeWebAuthnCredentialError, "Failed to finish registration", err)
}
client := dep.UAParser().Parse(s.UA)
name := util.Replace(map[string]string{
"{os}": client.Os.Family,
"{browser}": client.UserAgent.Family,
}, s.Name)
passkey, err := dep.UserClient().AddPasskey(c, u.ID, name, credential)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to add passkey", err)
}
res := BuildPasskey(passkey)
return &res, nil
}
type (
DeletePasskeyService struct {
ID string `form:"id" binding:"required"`
}
DeletePasskeyParameterCtx struct{}
)
func (s *DeletePasskeyService) DeletePasskey(c *gin.Context) error {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
userClient := dep.UserClient()
existingKeys, err := userClient.ListPasskeys(c, u.ID)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to list passkeys", err)
}
var existing *ent.Passkey
for _, key := range existingKeys {
if key.CredentialID == s.ID {
existing = key
break
}
}
if existing == nil {
return serializer.NewError(serializer.CodeNotFound, "Passkey not found", nil)
}
if err := userClient.RemovePasskey(c, u.ID, s.ID); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to delete passkey", err)
}
return nil
}