mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-08 10:49:06 +02:00
342 lines
11 KiB
Go
342 lines
11 KiB
Go
package devicelogin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/italypaleale/francis/actor"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
|
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
|
"github.com/pocket-id/pocket-id/backend/internal/auditlogs"
|
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
|
"github.com/pocket-id/pocket-id/backend/internal/iplocation"
|
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
|
)
|
|
|
|
const (
|
|
RequestDuration = 5 * time.Minute
|
|
PollingInterval = 3
|
|
longPollingDuration = 25 * time.Second
|
|
actorPollingInterval = 2 * time.Second
|
|
codePrefix = "P"
|
|
codeRandomLength = 7
|
|
reauthenticationMaxAge = time.Minute
|
|
// authenticationMethodOneTimePassword identifies the login-code-equivalent AMR used on the waiting device
|
|
authenticationMethodOneTimePassword = "otp"
|
|
)
|
|
|
|
type Service struct {
|
|
actService *actor.Service
|
|
db *gorm.DB
|
|
signer TokenService
|
|
reauth ReauthenticationTokenConsumer
|
|
auditLog AuditLogger
|
|
ipLocator iplocation.Resolver
|
|
}
|
|
|
|
type VerificationInfo struct {
|
|
UserCode string
|
|
Device string
|
|
IPAddress string
|
|
Country string
|
|
City string
|
|
ExpiresAt datatype.DateTime
|
|
}
|
|
|
|
func NewService(actService *actor.Service, db *gorm.DB, signer TokenService, reauth ReauthenticationTokenConsumer, auditLog AuditLogger, ipLocator iplocation.Resolver) *Service {
|
|
return &Service{
|
|
actService: actService,
|
|
db: db,
|
|
signer: signer,
|
|
reauth: reauth,
|
|
auditLog: auditLog,
|
|
ipLocator: ipLocator,
|
|
}
|
|
}
|
|
|
|
func (s *Service) Create(ctx context.Context, ipAddress, userAgent string) (Request, string, error) {
|
|
// Bind the public request to a separate high-entropy secret that never enters the QR code
|
|
deviceToken, err := utils.GenerateRandomAlphanumericString(32)
|
|
if err != nil {
|
|
return Request{}, "", err
|
|
}
|
|
deviceTokenHash := utils.CreateSha256Hash(deviceToken)
|
|
|
|
// Retry code generation because of the small but non-zero chance of a live actor collision
|
|
for range 3 {
|
|
code, codeErr := newUserCode()
|
|
if codeErr != nil {
|
|
return Request{}, "", codeErr
|
|
}
|
|
|
|
result, err := s.invoke(ctx, code, requestActorMethodCreate, requestActorCreateInput{
|
|
Code: code,
|
|
DeviceTokenHash: deviceTokenHash,
|
|
IPAddress: ipAddress,
|
|
UserAgent: userAgent,
|
|
})
|
|
if err != nil {
|
|
return Request{}, "", err
|
|
}
|
|
if result.Code == requestActorResultCollision {
|
|
continue
|
|
}
|
|
err = actorResultError(result.Code)
|
|
if err != nil {
|
|
return Request{}, "", err
|
|
}
|
|
|
|
return Request{
|
|
ID: code,
|
|
Code: code,
|
|
Status: result.Status,
|
|
ExpiresAt: datatype.DateTime(result.ExpiresAt),
|
|
}, deviceToken, nil
|
|
}
|
|
|
|
return Request{}, "", errors.New("failed to generate a unique device login code")
|
|
}
|
|
|
|
func (s *Service) Inspect(ctx context.Context, code string) (VerificationInfo, error) {
|
|
actorID := normalizeUserCode(code)
|
|
|
|
// Read the pending actor state without taking an exclusive actor turn
|
|
result, err := s.peek(ctx, actorID, requestActorMethodInspect, nil)
|
|
if err != nil {
|
|
return VerificationInfo{}, err
|
|
}
|
|
|
|
err = actorResultError(result.Code)
|
|
if err != nil {
|
|
return VerificationInfo{}, err
|
|
}
|
|
|
|
country, city, err := s.ipLocator.GetLocationByIP(ctx, result.IPAddress)
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "Failed to get device login request IP location", slog.String("ip", result.IPAddress), slog.Any("error", err))
|
|
}
|
|
|
|
return VerificationInfo{
|
|
UserCode: result.UserCode,
|
|
Device: s.auditLog.DeviceStringFromUserAgent(result.UserAgent),
|
|
IPAddress: result.IPAddress,
|
|
Country: country,
|
|
City: city,
|
|
ExpiresAt: datatype.DateTime(result.ExpiresAt),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) Decide(ctx context.Context, code, decision, userID, reauthenticationToken string) error {
|
|
actorID := normalizeUserCode(code)
|
|
|
|
// Consume the fresh passkey proof outside the actor before approving the request
|
|
if decision == "approve" {
|
|
if err := s.consumeReauthenticationProof(ctx, reauthenticationToken, userID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Let the actor serialize the decision with every competing exchange
|
|
result, err := s.invoke(ctx, actorID, requestActorMethodDecide, requestActorDecisionInput{
|
|
Decision: decision,
|
|
UserID: userID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return actorResultError(result.Code)
|
|
}
|
|
|
|
func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddress, userAgent, browserToken string, sessionDuration time.Duration, notificationMode appconfig.AppConfigValue) (dto.UserDto, model.LoginTokens, RequestStatus, error) {
|
|
if requestID == "" || deviceToken == "" || sessionDuration <= 0 {
|
|
return dto.UserDto{}, model.LoginTokens{}, "", apperror.DeviceLoginRequestInvalidOrExpired()
|
|
}
|
|
|
|
deviceTokenHash := utils.CreateSha256Hash(deviceToken)
|
|
timeout := time.NewTimer(longPollingDuration)
|
|
defer timeout.Stop()
|
|
ticker := time.NewTicker(actorPollingInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
// Poll the actor's activation cache so the long-lived HTTP request does not repeatedly query the database
|
|
result, err := s.peek(ctx, requestID, requestActorMethodPoll, requestActorPollInput{DeviceTokenHash: deviceTokenHash})
|
|
if err != nil {
|
|
return dto.UserDto{}, model.LoginTokens{}, "", err
|
|
}
|
|
|
|
err = actorResultError(result.Code)
|
|
if err != nil {
|
|
return dto.UserDto{}, model.LoginTokens{}, result.Status, err
|
|
}
|
|
|
|
switch result.Status {
|
|
case RequestStatusApproved:
|
|
// Validate the approved user before consuming so lookup failures leave the request untouched
|
|
user, userDTO, err := s.loadExchangeUser(ctx, result.UserID)
|
|
if err != nil {
|
|
return dto.UserDto{}, model.LoginTokens{}, result.Status, err
|
|
}
|
|
|
|
// Consume inside the actor so only one concurrent exchange can mint a token
|
|
consume, err := s.invoke(ctx, requestID, requestActorMethodConsume, requestActorConsumeInput{
|
|
DeviceTokenHash: deviceTokenHash,
|
|
})
|
|
if err != nil {
|
|
return dto.UserDto{}, model.LoginTokens{}, "", err
|
|
}
|
|
|
|
err = actorResultError(consume.Code)
|
|
if err != nil {
|
|
return dto.UserDto{}, model.LoginTokens{}, consume.Status, err
|
|
}
|
|
|
|
// Mint the session with login-code semantics because the waiting device did not perform WebAuthn
|
|
accessToken, err := s.signer.GenerateAccessToken(user, authenticationMethodOneTimePassword, sessionDuration)
|
|
if err != nil {
|
|
return dto.UserDto{}, model.LoginTokens{}, consume.Status, err
|
|
}
|
|
|
|
// Record the successful remote sign-in after the request has been consumed
|
|
signIn := s.auditLog.CreateSignIn(ctx, auditlogs.EventRemoteSignIn, ipAddress, userAgent, user.ID, browserToken, s.db, notificationMode)
|
|
if !signIn.Created {
|
|
return dto.UserDto{}, model.LoginTokens{}, consume.Status, errors.New("failed to create device login audit log")
|
|
}
|
|
|
|
s.auditLog.SendSignInNotification(ctx, signIn)
|
|
return userDTO, model.LoginTokens{AccessToken: accessToken, KnownBrowserToken: signIn.KnownBrowserToken}, consume.Status, nil
|
|
case RequestStatusPending:
|
|
// no-op
|
|
case RequestStatusDenied:
|
|
return dto.UserDto{}, model.LoginTokens{}, result.Status, apperror.DeviceLoginDenied()
|
|
default:
|
|
return dto.UserDto{}, model.LoginTokens{}, "", apperror.DeviceLoginRequestInvalidOrExpired()
|
|
}
|
|
|
|
select {
|
|
case <-ticker.C:
|
|
// no-op
|
|
case <-timeout.C:
|
|
return dto.UserDto{}, model.LoginTokens{}, RequestStatusPending, nil
|
|
case <-ctx.Done():
|
|
return dto.UserDto{}, model.LoginTokens{}, "", ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) consumeReauthenticationProof(ctx context.Context, token, userID string) error {
|
|
if token == "" {
|
|
return apperror.ReauthenticationRequired()
|
|
}
|
|
|
|
tx := s.db.WithContext(ctx).Begin()
|
|
if tx.Error != nil {
|
|
return tx.Error
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, tx, token, userID)
|
|
if err != nil {
|
|
if apperror.IsCode(err, apperror.CodeReauthenticationRequired) {
|
|
return apperror.ReauthenticationRequired()
|
|
}
|
|
return err
|
|
}
|
|
if time.Since(reauthenticatedAt) > reauthenticationMaxAge {
|
|
return apperror.ReauthenticationRequired()
|
|
}
|
|
|
|
if err = tx.Commit().Error; err != nil {
|
|
return fmt.Errorf("error committing database transaction: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) loadExchangeUser(ctx context.Context, userID string) (model.User, dto.UserDto, error) {
|
|
var user model.User
|
|
err := s.db.WithContext(ctx).First(&user, "id = ?", userID).Error
|
|
switch {
|
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
|
return model.User{}, dto.UserDto{}, apperror.DeviceLoginRequestInvalidOrExpired()
|
|
case err != nil:
|
|
return model.User{}, dto.UserDto{}, err
|
|
case user.Disabled:
|
|
return model.User{}, dto.UserDto{}, apperror.UserDisabled()
|
|
}
|
|
|
|
var userDTO dto.UserDto
|
|
if err = dto.MapStruct(user, &userDTO); err != nil {
|
|
return model.User{}, dto.UserDto{}, fmt.Errorf("failed to map exchanged device login user: %w", err)
|
|
}
|
|
|
|
return user, userDTO, nil
|
|
}
|
|
|
|
func normalizeUserCode(code string) string {
|
|
code = strings.ToUpper(strings.TrimSpace(code))
|
|
return utils.NormalizeUnambiguousString(code)
|
|
}
|
|
|
|
func (s *Service) invoke(ctx context.Context, actorID, method string, input any) (requestActorResult, error) {
|
|
envelope, err := s.actService.Invoke(ctx, requestActorType, actorID, method, input)
|
|
if err != nil {
|
|
return requestActorResult{}, err
|
|
}
|
|
|
|
return decodeActorResult(envelope)
|
|
}
|
|
|
|
func (s *Service) peek(ctx context.Context, actorID, method string, input any) (requestActorResult, error) {
|
|
envelope, err := s.actService.Peek(ctx, requestActorType, actorID, method, input)
|
|
if err != nil {
|
|
return requestActorResult{}, err
|
|
}
|
|
|
|
return decodeActorResult(envelope)
|
|
}
|
|
|
|
func decodeActorResult(envelope actor.Envelope) (requestActorResult, error) {
|
|
if envelope == nil {
|
|
return requestActorResult{}, errors.New("device login actor returned an empty response")
|
|
}
|
|
|
|
var result requestActorResult
|
|
err := envelope.Decode(&result)
|
|
if err != nil {
|
|
return requestActorResult{}, fmt.Errorf("failed to decode device login actor response: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func actorResultError(code requestActorResultCode) error {
|
|
switch code {
|
|
case requestActorResultNone:
|
|
return nil
|
|
case requestActorResultCollision:
|
|
return errors.New("unexpected live device login actor collision")
|
|
case requestActorResultInvalid:
|
|
return apperror.DeviceLoginRequestInvalidOrExpired()
|
|
case requestActorResultDenied:
|
|
return apperror.DeviceLoginDenied()
|
|
default:
|
|
return fmt.Errorf("unsupported device login actor result %q", code)
|
|
}
|
|
}
|
|
|
|
func newUserCode() (string, error) {
|
|
randomCode, err := utils.GenerateRandomUppercaseUnambiguousString(codeRandomLength)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return codePrefix + randomCode, nil
|
|
}
|