mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-17 21:34:07 +03:00
Compare commits
3 Commits
main
...
feat/qr-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5713d351aa | ||
|
|
f35402547f | ||
|
|
c079bc4b6c |
@@ -157,6 +157,11 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
|
||||
rateLimitMiddleware.Add(middleware.RateLimitWebauthnLogin),
|
||||
rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate),
|
||||
)
|
||||
svc.deviceLoginModule.RegisterRoutes(apiGroup,
|
||||
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(),
|
||||
rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginCreate),
|
||||
rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginVerification),
|
||||
)
|
||||
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
|
||||
controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService)
|
||||
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/devicelogin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -36,11 +37,12 @@ type services struct {
|
||||
appLockService *service.AppLockService
|
||||
oneTimeAccessService *service.OneTimeAccessService
|
||||
|
||||
apiKeyModule *apikey.Module
|
||||
oidcModule *oidc.Module
|
||||
webauthnModule *webauthn.Module
|
||||
userSignUpModule *usersignup.Module
|
||||
apiModule *api.Module
|
||||
apiKeyModule *apikey.Module
|
||||
deviceLoginModule *devicelogin.Module
|
||||
oidcModule *oidc.Module
|
||||
webauthnModule *webauthn.Module
|
||||
userSignUpModule *usersignup.Module
|
||||
apiModule *api.Module
|
||||
}
|
||||
|
||||
// Initializes all services
|
||||
@@ -79,6 +81,14 @@ func initServices(ctx context.Context, db *gorm.DB, instanceID string, httpClien
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create WebAuthn module: %w", err)
|
||||
}
|
||||
svc.deviceLoginModule = devicelogin.New(devicelogin.Dependencies{
|
||||
DB: db,
|
||||
BaseURL: common.EnvConfig.AppURL,
|
||||
Signer: svc.jwtService,
|
||||
Reauth: svc.webauthnModule,
|
||||
AuditLog: svc.auditLogService,
|
||||
AppConfig: svc.appConfigService,
|
||||
})
|
||||
|
||||
svc.scimService = service.NewScimService(db, scheduler, httpClient)
|
||||
|
||||
|
||||
@@ -181,6 +181,20 @@ type OneTimeAccessDisabledError struct{}
|
||||
func (e OneTimeAccessDisabledError) Error() string { return "One-time access is disabled" }
|
||||
func (e OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type DeviceLoginRequestInvalidOrExpiredError struct{}
|
||||
|
||||
func (e DeviceLoginRequestInvalidOrExpiredError) Error() string {
|
||||
return "Device login request is invalid or expired"
|
||||
}
|
||||
func (e DeviceLoginRequestInvalidOrExpiredError) HttpStatusCode() int {
|
||||
return http.StatusUnauthorized
|
||||
}
|
||||
|
||||
type DeviceLoginDeniedError struct{}
|
||||
|
||||
func (e DeviceLoginDeniedError) Error() string { return "Device login request was denied" }
|
||||
func (e DeviceLoginDeniedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type InvalidAPIKeyError struct{}
|
||||
|
||||
func (e InvalidAPIKeyError) Error() string { return "Invalid Api Key" }
|
||||
|
||||
19
backend/internal/devicelogin/cleanup.go
Normal file
19
backend/internal/devicelogin/cleanup.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package devicelogin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
// CleanupExpiredRequests deletes device login requests that have expired
|
||||
// It returns the number of rows removed
|
||||
func CleanupExpiredRequests(ctx context.Context, db *gorm.DB) (int64, error) {
|
||||
statement := db.
|
||||
WithContext(ctx).
|
||||
Delete(&Request{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||
return statement.RowsAffected, statement.Error
|
||||
}
|
||||
46
backend/internal/devicelogin/cleanup_test.go
Normal file
46
backend/internal/devicelogin/cleanup_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
//go:build unit
|
||||
|
||||
package devicelogin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestCleanupExpiredRequests(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
requests := []Request{
|
||||
{
|
||||
Base: model.Base{ID: "expired-device-login-request"},
|
||||
Code: "PAAAAAAA",
|
||||
DeviceTokenHash: "expired-token-hash",
|
||||
Status: RequestStatusPending,
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(-time.Minute)),
|
||||
UserAgent: "expired-agent",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "active-device-login-request"},
|
||||
Code: "PBBBBBBB",
|
||||
DeviceTokenHash: "active-token-hash",
|
||||
Status: RequestStatusPending,
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(time.Minute)),
|
||||
UserAgent: "active-agent",
|
||||
},
|
||||
}
|
||||
require.NoError(t, db.Create(&requests).Error)
|
||||
|
||||
deleted, err := CleanupExpiredRequests(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), deleted)
|
||||
|
||||
var remaining []Request
|
||||
require.NoError(t, db.Order("id").Find(&remaining).Error)
|
||||
require.Len(t, remaining, 1)
|
||||
require.Equal(t, "active-device-login-request", remaining[0].ID)
|
||||
}
|
||||
28
backend/internal/devicelogin/dto.go
Normal file
28
backend/internal/devicelogin/dto.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package devicelogin
|
||||
|
||||
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
|
||||
type requestCreateDto struct {
|
||||
ID string `json:"id"`
|
||||
UserCode string `json:"userCode"`
|
||||
VerificationURI string `json:"verificationUri"`
|
||||
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
type verificationDto struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
type decisionDto struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
Decision string `json:"decision" binding:"required,oneof=approve deny"`
|
||||
}
|
||||
|
||||
type verificationInfoDto struct {
|
||||
UserCode string `json:"userCode"`
|
||||
Device string `json:"device"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
}
|
||||
134
backend/internal/devicelogin/handler.go
Normal file
134
backend/internal/devicelogin/handler.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package devicelogin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
service *Service
|
||||
baseURL string
|
||||
appConfig AppConfigProvider
|
||||
}
|
||||
|
||||
func newHandler(service *Service, baseURL string, appConfig AppConfigProvider) *handler {
|
||||
return &handler{
|
||||
service: service,
|
||||
baseURL: baseURL,
|
||||
appConfig: appConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// createRequest godoc
|
||||
// @Summary Create device login request
|
||||
// @Description Create a short-lived request that can be approved from another authenticated device
|
||||
// @Tags Device Login
|
||||
// @Produce json
|
||||
// @Success 201 {object} requestCreateDto "Created device login request"
|
||||
// @Router /api/device-login/requests [post]
|
||||
func (h *handler) createRequest(c *gin.Context) {
|
||||
request, deviceToken, err := h.service.Create(c.Request.Context(), c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
verificationURI := h.baseURL + "/device"
|
||||
verificationURIComplete := verificationURI + "?user_code=" + url.QueryEscape(request.Code)
|
||||
cookie.AddDeviceLoginTokenCookie(c, request.ID, deviceToken)
|
||||
c.JSON(http.StatusCreated, requestCreateDto{
|
||||
ID: request.ID,
|
||||
UserCode: request.Code,
|
||||
VerificationURI: verificationURI,
|
||||
VerificationURIComplete: verificationURIComplete,
|
||||
ExpiresAt: request.ExpiresAt,
|
||||
Interval: PollingInterval,
|
||||
})
|
||||
}
|
||||
|
||||
// exchangeRequest godoc
|
||||
// @Summary Exchange device login request
|
||||
// @Description Poll a device login request and create a browser session after it has been approved
|
||||
// @Tags Device Login
|
||||
// @Produce json
|
||||
// @Param id path string true "Device login request ID"
|
||||
// @Success 200 {object} dto.UserDto "Approved request exchanged for a user session"
|
||||
// @Success 202 "Authorization pending"
|
||||
// @Router /api/device-login/requests/{id}/exchange [post]
|
||||
func (h *handler) exchangeRequest(c *gin.Context) {
|
||||
requestID := c.Param("id")
|
||||
deviceToken, _ := c.Cookie(cookie.DeviceLoginTokenCookieName)
|
||||
user, accessToken, status, err := h.service.Exchange(c.Request.Context(), requestID, deviceToken, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
if status == RequestStatusPending {
|
||||
c.Status(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err = dto.MapStruct(user, &userDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
|
||||
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
// inspectRequest godoc
|
||||
// @Summary Inspect device login request
|
||||
// @Description Retrieve the requesting device details for an authenticated user before approval or denial
|
||||
// @Tags Device Login
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body verificationDto true "Device login code"
|
||||
// @Success 200 {object} verificationInfoDto "Device login request details"
|
||||
// @Router /api/device-login/verification [post]
|
||||
func (h *handler) inspectRequest(c *gin.Context) {
|
||||
var input verificationDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := h.service.Inspect(c.Request.Context(), input.Code)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, verificationInfoDto(info))
|
||||
}
|
||||
|
||||
// decideRequest godoc
|
||||
// @Summary Decide device login request
|
||||
// @Description Approve or deny a device login request; approval requires fresh passkey reauthentication
|
||||
// @Tags Device Login
|
||||
// @Accept json
|
||||
// @Param decision body decisionDto true "Device login decision"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/device-login/verification/decision [post]
|
||||
func (h *handler) decideRequest(c *gin.Context) {
|
||||
var input decisionDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
|
||||
if err := h.service.Decide(c.Request.Context(), input.Code, input.Decision, c.GetString("userID"), reauthenticationToken); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
32
backend/internal/devicelogin/models.go
Normal file
32
backend/internal/devicelogin/models.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package devicelogin
|
||||
|
||||
import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type RequestStatus string
|
||||
|
||||
const (
|
||||
RequestStatusPending RequestStatus = "pending"
|
||||
RequestStatusApproved RequestStatus = "approved"
|
||||
RequestStatusDenied RequestStatus = "denied"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
model.Base
|
||||
|
||||
Code string
|
||||
DeviceTokenHash string
|
||||
Status RequestStatus
|
||||
ExpiresAt datatype.DateTime
|
||||
IpAddress string
|
||||
UserAgent string
|
||||
|
||||
UserID *string
|
||||
User model.User
|
||||
}
|
||||
|
||||
func (Request) TableName() string {
|
||||
return "device_login_requests"
|
||||
}
|
||||
59
backend/internal/devicelogin/module.go
Normal file
59
backend/internal/devicelogin/module.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package devicelogin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
type TokenService interface {
|
||||
GenerateAccessToken(user model.User, authenticationMethod string) (string, error)
|
||||
}
|
||||
|
||||
type ReauthenticationTokenConsumer interface {
|
||||
ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error)
|
||||
}
|
||||
|
||||
type AuditLogger interface {
|
||||
Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool)
|
||||
DeviceStringFromUserAgent(userAgent string) string
|
||||
}
|
||||
|
||||
type AppConfigProvider interface {
|
||||
GetDbConfig() *model.AppConfig
|
||||
}
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
BaseURL string
|
||||
|
||||
Signer TokenService
|
||||
Reauth ReauthenticationTokenConsumer
|
||||
AuditLog AuditLogger
|
||||
AppConfig AppConfigProvider
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
service *Service
|
||||
handler *handler
|
||||
}
|
||||
|
||||
func New(deps Dependencies) *Module {
|
||||
service := newService(deps)
|
||||
return &Module{
|
||||
service: service,
|
||||
handler: newHandler(service, deps.BaseURL, deps.AppConfig),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts the public exchange and authenticated verification endpoints
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, browserAuth, createRateLimit, verificationRateLimit gin.HandlerFunc) {
|
||||
apiGroup.POST("/device-login/requests", createRateLimit, m.handler.createRequest)
|
||||
apiGroup.POST("/device-login/requests/:id/exchange", m.handler.exchangeRequest)
|
||||
apiGroup.POST("/device-login/verification", verificationRateLimit, browserAuth, m.handler.inspectRequest)
|
||||
apiGroup.POST("/device-login/verification/decision", verificationRateLimit, browserAuth, m.handler.decideRequest)
|
||||
}
|
||||
242
backend/internal/devicelogin/service.go
Normal file
242
backend/internal/devicelogin/service.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package devicelogin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"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 = 15 * time.Minute
|
||||
PollingInterval = 3
|
||||
codePrefix = "P"
|
||||
codeRandomLength = 7
|
||||
reauthenticationMaxAge = time.Minute
|
||||
// authenticationMethodOneTimePassword identifies the login-code-equivalent AMR used on the waiting device
|
||||
authenticationMethodOneTimePassword = "otp"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
signer TokenService
|
||||
auditLog AuditLogger
|
||||
reauth ReauthenticationTokenConsumer
|
||||
}
|
||||
|
||||
type VerificationInfo struct {
|
||||
UserCode string
|
||||
Device string
|
||||
IPAddress string
|
||||
ExpiresAt datatype.DateTime
|
||||
}
|
||||
|
||||
func newService(deps Dependencies) *Service {
|
||||
return &Service{
|
||||
db: deps.DB,
|
||||
signer: deps.Signer,
|
||||
auditLog: deps.AuditLog,
|
||||
reauth: deps.Reauth,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
now := time.Now().Round(time.Second)
|
||||
request := Request{
|
||||
DeviceTokenHash: utils.CreateSha256Hash(deviceToken),
|
||||
Status: RequestStatusPending,
|
||||
ExpiresAt: datatype.DateTime(now.Add(RequestDuration)),
|
||||
UserAgent: userAgent,
|
||||
IpAddress: ipAddress,
|
||||
}
|
||||
|
||||
// Retry code generation because of the small but non-zero chance of a collision with an existing code
|
||||
for range 3 {
|
||||
request.Code, err = newUserCode()
|
||||
if err != nil {
|
||||
return Request{}, "", err
|
||||
}
|
||||
|
||||
err = s.db.WithContext(ctx).Create(&request).Error
|
||||
if err == nil {
|
||||
return request, deviceToken, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return Request{}, "", err
|
||||
}
|
||||
}
|
||||
|
||||
return Request{}, "", errors.New("failed to generate a unique device login code")
|
||||
}
|
||||
|
||||
func (s *Service) Inspect(ctx context.Context, code string) (VerificationInfo, error) {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
|
||||
// Return requester metadata only while the request can still be decided
|
||||
var request Request
|
||||
err := s.db.
|
||||
WithContext(ctx).
|
||||
Where("code = ? AND status = ? AND expires_at > ?", code, RequestStatusPending, datatype.DateTime(time.Now())).
|
||||
First(&request).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return VerificationInfo{}, &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
}
|
||||
return VerificationInfo{}, err
|
||||
}
|
||||
|
||||
return VerificationInfo{
|
||||
UserCode: request.Code,
|
||||
Device: s.auditLog.DeviceStringFromUserAgent(request.UserAgent),
|
||||
IPAddress: request.IpAddress,
|
||||
ExpiresAt: request.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Decide(ctx context.Context, code, decision, userID, reauthenticationToken string) error {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
|
||||
tx := s.db.Begin()
|
||||
defer tx.Rollback()
|
||||
|
||||
var request Request
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("code = ? AND status = ? AND expires_at > ?", code, RequestStatusPending, datatype.DateTime(time.Now())).
|
||||
First(&request).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
switch decision {
|
||||
case "approve":
|
||||
// Consume the fresh passkey proof in the same transaction as the approval
|
||||
if reauthenticationToken == "" {
|
||||
return &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
reauthenticatedAt, consumeErr := s.reauth.ConsumeReauthenticationToken(ctx, tx, reauthenticationToken, userID)
|
||||
if consumeErr != nil {
|
||||
return consumeErr
|
||||
}
|
||||
if time.Since(reauthenticatedAt) > reauthenticationMaxAge {
|
||||
return &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
request.Status = RequestStatusApproved
|
||||
request.UserID = &userID
|
||||
case "deny":
|
||||
request.Status = RequestStatusDenied
|
||||
default:
|
||||
return fmt.Errorf("unsupported device login decision %q", decision)
|
||||
}
|
||||
|
||||
result := tx.
|
||||
WithContext(ctx).
|
||||
Model(&Request{}).
|
||||
Where("id = ? AND status = ? AND expires_at > ?", request.ID, RequestStatusPending, datatype.DateTime(time.Now())).
|
||||
Updates(map[string]any{"status": request.Status, "user_id": request.UserID})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddress, userAgent string) (model.User, string, RequestStatus, error) {
|
||||
if deviceToken == "" {
|
||||
return model.User{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
}
|
||||
|
||||
tx := s.db.Begin()
|
||||
defer tx.Rollback()
|
||||
|
||||
var request Request
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Preload("User").
|
||||
Where("id = ? AND device_token_hash = ? AND expires_at > ?", requestID, utils.CreateSha256Hash(deviceToken), datatype.DateTime(time.Now())).
|
||||
First(&request).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
}
|
||||
return model.User{}, "", "", err
|
||||
}
|
||||
|
||||
switch request.Status {
|
||||
case RequestStatusPending:
|
||||
// Leave pending requests untouched so the waiting device can continue polling
|
||||
return model.User{}, "", request.Status, nil
|
||||
case RequestStatusDenied:
|
||||
return model.User{}, "", request.Status, &common.DeviceLoginDeniedError{}
|
||||
case RequestStatusApproved:
|
||||
default:
|
||||
return model.User{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
}
|
||||
|
||||
if request.UserID == nil || request.User.ID == "" {
|
||||
return model.User{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
}
|
||||
if request.User.Disabled {
|
||||
return model.User{}, "", "", &common.UserDisabledError{}
|
||||
}
|
||||
|
||||
// Mint the session with login-code semantics because the waiting device did not perform WebAuthn
|
||||
accessToken, err := s.signer.GenerateAccessToken(request.User, authenticationMethodOneTimePassword)
|
||||
if err != nil {
|
||||
return model.User{}, "", "", err
|
||||
}
|
||||
|
||||
result := tx.
|
||||
WithContext(ctx).
|
||||
Where("id = ? AND status = ?", request.ID, RequestStatusApproved).
|
||||
Delete(&Request{})
|
||||
if result.Error != nil {
|
||||
return model.User{}, "", "", result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return model.User{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
}
|
||||
|
||||
s.auditLog.Create(ctx, model.AuditLogEventRemoteSignIn, ipAddress, userAgent, request.User.ID, model.AuditLogData{}, tx)
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return model.User{}, "", "", err
|
||||
}
|
||||
|
||||
return request.User, accessToken, request.Status, nil
|
||||
}
|
||||
|
||||
func newUserCode() (string, error) {
|
||||
randomCode, err := utils.GenerateRandomUppercaseUnambiguousString(codeRandomLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return codePrefix + randomCode, nil
|
||||
}
|
||||
269
backend/internal/devicelogin/service_test.go
Normal file
269
backend/internal/devicelogin/service_test.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package devicelogin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"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"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
type fakeReauthenticationTokenConsumer struct {
|
||||
expectedValue string
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
func (f *fakeReauthenticationTokenConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, _ string) (time.Time, error) {
|
||||
if token != f.expectedValue {
|
||||
return time.Time{}, &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
if !f.createdAt.IsZero() {
|
||||
return f.createdAt, nil
|
||||
}
|
||||
return time.Now(), nil
|
||||
}
|
||||
|
||||
type fakeTokenService struct {
|
||||
mu sync.Mutex
|
||||
userID string
|
||||
authenticationMethod string
|
||||
}
|
||||
|
||||
func (f *fakeTokenService) GenerateAccessToken(user model.User, authenticationMethod string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.userID = user.ID
|
||||
f.authenticationMethod = authenticationMethod
|
||||
return "device-login-access-token", nil
|
||||
}
|
||||
|
||||
func (f *fakeTokenService) generatedToken() (string, string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.userID, f.authenticationMethod
|
||||
}
|
||||
|
||||
type auditEntry struct {
|
||||
event model.AuditLogEvent
|
||||
ipAddress string
|
||||
userAgent string
|
||||
userID string
|
||||
}
|
||||
|
||||
type fakeAuditLogger struct {
|
||||
mu sync.Mutex
|
||||
entries []auditEntry
|
||||
}
|
||||
|
||||
func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, _ model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.entries = append(f.entries, auditEntry{event: event, ipAddress: ipAddress, userAgent: userAgent, userID: userID})
|
||||
return model.AuditLog{}, true
|
||||
}
|
||||
|
||||
func (f *fakeAuditLogger) DeviceStringFromUserAgent(userAgent string) string {
|
||||
return "Parsed " + userAgent
|
||||
}
|
||||
|
||||
func (f *fakeAuditLogger) lastEntry() auditEntry {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.entries[len(f.entries)-1]
|
||||
}
|
||||
|
||||
func TestRequestLifecycle(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
deviceLoginService, signer, auditLog := newServiceForTest(db)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "device-login-user"},
|
||||
Username: "device-login-user",
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
request, deviceToken, err := deviceLoginService.Create(t.Context(), "192.0.2.10", "Mozilla/5.0 Chrome/125.0.0.0")
|
||||
require.NoError(t, err)
|
||||
require.Regexp(t, `^P[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{7}$`, request.Code)
|
||||
require.Equal(t, utils.CreateSha256Hash(deviceToken), request.DeviceTokenHash)
|
||||
require.NotEqual(t, deviceToken, request.DeviceTokenHash)
|
||||
require.Equal(t, RequestStatusPending, request.Status)
|
||||
|
||||
info, err := deviceLoginService.Inspect(t.Context(), strings.ToLower(request.Code))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, request.Code, info.UserCode)
|
||||
require.Equal(t, "192.0.2.10", info.IPAddress)
|
||||
require.Equal(t, "Parsed Mozilla/5.0 Chrome/125.0.0.0", info.Device)
|
||||
|
||||
err = deviceLoginService.Decide(t.Context(), strings.ToLower(request.Code), "approve", user.ID, "fresh-proof")
|
||||
require.NoError(t, err)
|
||||
|
||||
exchangedUser, accessToken, status, err := deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "198.51.100.20", "target-agent")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, RequestStatusApproved, status)
|
||||
require.Equal(t, user.ID, exchangedUser.ID)
|
||||
require.Equal(t, "device-login-access-token", accessToken)
|
||||
|
||||
signedUserID, authenticationMethod := signer.generatedToken()
|
||||
require.Equal(t, user.ID, signedUserID)
|
||||
require.Equal(t, authenticationMethodOneTimePassword, authenticationMethod)
|
||||
|
||||
var requestCount int64
|
||||
require.NoError(t, db.Model(&Request{}).Where("id = ?", request.ID).Count(&requestCount).Error)
|
||||
require.Zero(t, requestCount)
|
||||
|
||||
entry := auditLog.lastEntry()
|
||||
require.Equal(t, model.AuditLogEventRemoteSignIn, entry.event)
|
||||
require.Equal(t, "198.51.100.20", entry.ipAddress)
|
||||
require.Equal(t, "target-agent", entry.userAgent)
|
||||
require.Equal(t, user.ID, entry.userID)
|
||||
}
|
||||
|
||||
func TestPendingAndDeniedRequests(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
deviceLoginService, _, _ := newServiceForTest(db)
|
||||
|
||||
request, deviceToken, err := deviceLoginService.Create(t.Context(), "", "requesting-agent")
|
||||
require.NoError(t, err)
|
||||
|
||||
user, accessToken, status, err := deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, RequestStatusPending, status)
|
||||
require.Empty(t, user.ID)
|
||||
require.Empty(t, accessToken)
|
||||
|
||||
err = deviceLoginService.Decide(t.Context(), request.Code, "deny", "device-login-user", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
user, accessToken, status, err = deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "", "")
|
||||
var deniedError *common.DeviceLoginDeniedError
|
||||
require.ErrorAs(t, err, &deniedError)
|
||||
require.Equal(t, RequestStatusDenied, status)
|
||||
require.Empty(t, user.ID)
|
||||
require.Empty(t, accessToken)
|
||||
}
|
||||
|
||||
func TestRejectsInvalidAndExpiredRequests(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
deviceLoginService, _, _ := newServiceForTest(db)
|
||||
|
||||
request, deviceToken, err := deviceLoginService.Create(t.Context(), "", "requesting-agent")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, _, err = deviceLoginService.Exchange(t.Context(), request.ID, "wrong-token", "", "")
|
||||
var invalidError *common.DeviceLoginRequestInvalidOrExpiredError
|
||||
require.ErrorAs(t, err, &invalidError)
|
||||
|
||||
require.NoError(t, db.Model(&request).Update("expires_at", datatype.DateTime(time.Now().Add(-time.Minute))).Error)
|
||||
_, err = deviceLoginService.Inspect(t.Context(), request.Code)
|
||||
require.ErrorAs(t, err, &invalidError)
|
||||
err = deviceLoginService.Decide(t.Context(), request.Code, "deny", "device-login-user", "")
|
||||
require.ErrorAs(t, err, &invalidError)
|
||||
_, _, _, err = deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "", "")
|
||||
require.ErrorAs(t, err, &invalidError)
|
||||
|
||||
}
|
||||
|
||||
func TestRejectsDisabledUserAtExchange(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
deviceLoginService, _, _ := newServiceForTest(db)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "disabled-device-login-user"},
|
||||
Username: "disabled-device-login-user",
|
||||
Disabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
request, deviceToken, err := deviceLoginService.Create(t.Context(), "", "requesting-agent")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, deviceLoginService.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
|
||||
|
||||
_, accessToken, _, err := deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "", "")
|
||||
var disabledError *common.UserDisabledError
|
||||
require.ErrorAs(t, err, &disabledError)
|
||||
require.Empty(t, accessToken)
|
||||
|
||||
var remainingRequest Request
|
||||
require.NoError(t, db.First(&remainingRequest, "id = ?", request.ID).Error)
|
||||
}
|
||||
|
||||
func TestApprovalRejectsMissingAndStaleReauthentication(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
deviceLoginService, _, _ := newServiceForTest(db)
|
||||
|
||||
request, _, err := deviceLoginService.Create(t.Context(), "", "requesting-agent")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = deviceLoginService.Decide(t.Context(), request.Code, "approve", "device-login-user", "")
|
||||
var reauthenticationError *common.ReauthenticationRequiredError
|
||||
require.ErrorAs(t, err, &reauthenticationError)
|
||||
|
||||
deviceLoginService.reauth = &fakeReauthenticationTokenConsumer{
|
||||
expectedValue: "stale-proof",
|
||||
createdAt: time.Now().Add(-2 * time.Minute),
|
||||
}
|
||||
err = deviceLoginService.Decide(t.Context(), request.Code, "approve", "device-login-user", "stale-proof")
|
||||
require.ErrorAs(t, err, &reauthenticationError)
|
||||
|
||||
var persistedRequest Request
|
||||
require.NoError(t, db.First(&persistedRequest, "id = ?", request.ID).Error)
|
||||
require.Equal(t, RequestStatusPending, persistedRequest.Status)
|
||||
}
|
||||
|
||||
func TestExchangeIsSingleUse(t *testing.T) {
|
||||
db := testutils.NewConcurrentDatabaseForTest(t)
|
||||
deviceLoginService, _, _ := newServiceForTest(db)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "single-use-device-login-user"},
|
||||
Username: "single-use-device-login-user",
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
request, deviceToken, err := deviceLoginService.Create(t.Context(), "", "requesting-agent")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, deviceLoginService.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
|
||||
|
||||
var waitGroup sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
for range 2 {
|
||||
waitGroup.Add(1)
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
_, _, _, exchangeErr := deviceLoginService.Exchange(t.Context(), request.ID, deviceToken, "", "")
|
||||
results <- exchangeErr
|
||||
}()
|
||||
}
|
||||
waitGroup.Wait()
|
||||
close(results)
|
||||
|
||||
var successes int
|
||||
for result := range results {
|
||||
if result == nil {
|
||||
successes++
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, successes)
|
||||
}
|
||||
|
||||
func newServiceForTest(db *gorm.DB) (*Service, *fakeTokenService, *fakeAuditLogger) {
|
||||
signer := &fakeTokenService{}
|
||||
auditLog := &fakeAuditLogger{}
|
||||
deviceLoginService := newService(Dependencies{
|
||||
DB: db,
|
||||
Signer: signer,
|
||||
AuditLog: auditLog,
|
||||
Reauth: &fakeReauthenticationTokenConsumer{expectedValue: "fresh-proof"},
|
||||
})
|
||||
return deviceLoginService, signer, auditLog
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/devicelogin"
|
||||
"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/oidc"
|
||||
@@ -35,6 +36,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
|
||||
return errors.Join(
|
||||
s.RegisterJob(ctx, "ClearWebauthnSessions", jobDefWithJitter(24*time.Hour), jobs.clearWebauthnSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", jobDefWithJitter(24*time.Hour), jobs.clearOneTimeAccessTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearDeviceLoginRequests", jobDefWithJitter(24*time.Hour), jobs.clearDeviceLoginRequests, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearSignupTokens", jobDefWithJitter(24*time.Hour), jobs.clearSignupTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOAuth2Sessions", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2Sessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
@@ -75,6 +77,18 @@ func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearDeviceLoginRequests deletes device login requests that have expired
|
||||
func (j *DbCleanupJobs) clearDeviceLoginRequests(ctx context.Context) error {
|
||||
count, err := devicelogin.CleanupExpiredRequests(ctx, j.db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clean expired device login requests: %w", err)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Cleaned expired device login requests", slog.Int64("count", count))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearSignupTokens deletes signup tokens that have expired
|
||||
func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error {
|
||||
count, err := usersignup.CleanupExpiredSignupTokens(ctx, j.db)
|
||||
|
||||
@@ -19,15 +19,17 @@ import (
|
||||
// Rate-limit policy names
|
||||
// Each constant names a limiter registered on the actor host and is the value passed to Add to select that limiter
|
||||
const (
|
||||
RateLimitAPI = "api"
|
||||
RateLimitSignup = "signup"
|
||||
RateLimitWebauthnLogin = "webauthn-login"
|
||||
RateLimitWebauthnReauthenticate = "webauthn-reauthenticate"
|
||||
RateLimitOneTimeAccessToken = "one-time-access-token"
|
||||
RateLimitOneTimeAccessEmail = "one-time-access-email"
|
||||
RateLimitSendEmailVerification = "send-email-verification"
|
||||
RateLimitVerifyEmail = "verify-email"
|
||||
RateLimitInternal = "internal"
|
||||
RateLimitAPI = "api"
|
||||
RateLimitSignup = "signup"
|
||||
RateLimitWebauthnLogin = "webauthn-login"
|
||||
RateLimitWebauthnReauthenticate = "webauthn-reauthenticate"
|
||||
RateLimitOneTimeAccessToken = "one-time-access-token"
|
||||
RateLimitOneTimeAccessEmail = "one-time-access-email"
|
||||
RateLimitDeviceLoginCreate = "device-login-create"
|
||||
RateLimitDeviceLoginVerification = "device-login-verification"
|
||||
RateLimitSendEmailVerification = "send-email-verification"
|
||||
RateLimitVerifyEmail = "verify-email"
|
||||
RateLimitInternal = "internal"
|
||||
)
|
||||
|
||||
// RateLimitPolicy is the configuration for a single rate-limit actor
|
||||
@@ -53,6 +55,8 @@ func RateLimitPolicies() []RateLimitPolicy {
|
||||
{Name: RateLimitWebauthnReauthenticate, Rate: 1, Per: 10 * time.Second, Burst: 5},
|
||||
{Name: RateLimitOneTimeAccessToken, Rate: 1, Per: 10 * time.Second, Burst: 5},
|
||||
{Name: RateLimitOneTimeAccessEmail, Rate: 2, Per: 10 * time.Minute, Burst: 5},
|
||||
{Name: RateLimitDeviceLoginCreate, Rate: 1, Per: 10 * time.Second, Burst: 5},
|
||||
{Name: RateLimitDeviceLoginVerification, Rate: 1, Per: 10 * time.Second, Burst: 5},
|
||||
{Name: RateLimitSendEmailVerification, Rate: 2, Per: 10 * time.Minute, Burst: 1},
|
||||
{Name: RateLimitVerifyEmail, Rate: 1, Per: 10 * time.Second, Burst: 5},
|
||||
{Name: RateLimitInternal, Rate: 20, Per: time.Second, Burst: 20},
|
||||
|
||||
@@ -29,6 +29,7 @@ type AuditLogEvent string //nolint:recvcheck
|
||||
const (
|
||||
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
||||
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
||||
AuditLogEventRemoteSignIn AuditLogEvent = "REMOTE_SIGN_IN"
|
||||
AuditLogEventAccountCreated AuditLogEvent = "ACCOUNT_CREATED"
|
||||
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
||||
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
@@ -219,6 +220,8 @@ func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID
|
||||
}
|
||||
|
||||
func (s *deviceService) deviceRequestFromUserCode(ctx context.Context, userCode string) (fosite.DeviceRequester, string, error) {
|
||||
userCode = strings.ToUpper(strings.TrimSpace(userCode))
|
||||
|
||||
userCodeSignature, err := s.userCodeStrategy.UserCodeSignature(ctx, userCode)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
||||
@@ -61,6 +61,14 @@ func TestDeviceServiceAcceptRequiresReauthenticationTokenWhenClientRequiresIt(t
|
||||
require.Equal(t, 1, reauth.calls)
|
||||
}
|
||||
|
||||
func TestDeviceServiceCreatesUserCodeWithOAuthPrefix(t *testing.T) {
|
||||
service, _, _, userCode, _ := newTestDeviceServiceWithCode(t, "test-client", "test-user", false, nil)
|
||||
|
||||
require.Regexp(t, `^E[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{7}$`, userCode)
|
||||
_, _, err := service.deviceRequestFromUserCode(t.Context(), strings.ToLower(userCode))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDeviceServiceAcceptUsesReauthenticationTimeForDeviceSession(t *testing.T) {
|
||||
const (
|
||||
userID = "test-user"
|
||||
|
||||
32
backend/internal/oidc/device_strategy.go
Normal file
32
backend/internal/oidc/device_strategy.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/fosite/handler/rfc8628"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
oauthDeviceUserCodePrefix = "E"
|
||||
oauthDeviceUserCodeRandomLength = 7
|
||||
)
|
||||
|
||||
type deviceStrategy struct {
|
||||
*rfc8628.DefaultDeviceStrategy
|
||||
}
|
||||
|
||||
func (s *deviceStrategy) GenerateUserCode(ctx context.Context) (string, string, error) {
|
||||
userCode, err := utils.GenerateRandomUppercaseUnambiguousString(oauthDeviceUserCodeRandomLength)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
userCode = oauthDeviceUserCodePrefix + userCode
|
||||
signature, err := s.UserCodeSignature(ctx, userCode)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return userCode, signature, nil
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/ory/fosite/compose"
|
||||
fositeoauth2 "github.com/ory/fosite/handler/oauth2"
|
||||
"github.com/ory/fosite/handler/openid"
|
||||
"github.com/ory/fosite/handler/rfc8628"
|
||||
"github.com/ory/fosite/token/jwt"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
@@ -20,7 +19,7 @@ import (
|
||||
|
||||
type oidcProvider struct {
|
||||
fosite.OAuth2Provider
|
||||
deviceStrategy *rfc8628.DefaultDeviceStrategy
|
||||
deviceStrategy *deviceStrategy
|
||||
tokenStrategies
|
||||
}
|
||||
|
||||
@@ -64,7 +63,7 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign
|
||||
}
|
||||
sig := newJWTSigner(keyGetter)
|
||||
coreStrategy := compose.NewOAuth2HMACStrategy(fositeConfig)
|
||||
deviceStrategy := compose.NewDeviceStrategy(fositeConfig)
|
||||
deviceStrategy := &deviceStrategy{DefaultDeviceStrategy: compose.NewDeviceStrategy(fositeConfig)}
|
||||
accessTokenStrategy := &fositeoauth2.DefaultJWTStrategy{
|
||||
Signer: sig,
|
||||
HMACSHAStrategy: coreStrategy,
|
||||
|
||||
@@ -18,6 +18,11 @@ func AddDeviceTokenCookie(c *gin.Context, deviceToken string) {
|
||||
c.SetCookie(DeviceTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), "/api/one-time-access-token", "", true, true)
|
||||
}
|
||||
|
||||
func AddDeviceLoginTokenCookie(c *gin.Context, requestID, deviceToken string) {
|
||||
path := "/api/device-login/requests/" + requestID + "/exchange"
|
||||
c.SetCookie(DeviceLoginTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), path, "", true, true)
|
||||
}
|
||||
|
||||
func AddReauthenticationTokenCookie(c *gin.Context, reauthenticationToken string) {
|
||||
c.SetCookie(ReauthenticationTokenCookieName, reauthenticationToken, int(3*time.Minute.Seconds()), "/", "", true, true)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
var AccessTokenCookieName = "__Host-access_token"
|
||||
var SessionIdCookieName = "__Host-session"
|
||||
var DeviceTokenCookieName = "__Secure-device_token" //nolint:gosec
|
||||
var DeviceLoginTokenCookieName = "__Secure-device_login_token" //nolint:gosec
|
||||
var ReauthenticationTokenCookieName = "__Secure-reauthentication_token" //nolint:gosec
|
||||
|
||||
func init() {
|
||||
@@ -16,6 +17,7 @@ func init() {
|
||||
AccessTokenCookieName = "access_token"
|
||||
SessionIdCookieName = "session"
|
||||
DeviceTokenCookieName = "device_token"
|
||||
DeviceLoginTokenCookieName = "device_login_token"
|
||||
ReauthenticationTokenCookieName = "reauthentication_token"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ func GenerateRandomUnambiguousString(length int) (string, error) {
|
||||
return GenerateRandomString(length, charset)
|
||||
}
|
||||
|
||||
// GenerateRandomUppercaseUnambiguousString generates a random uppercase string of the given length using unambiguous characters
|
||||
func GenerateRandomUppercaseUnambiguousString(length int) (string, error) {
|
||||
const charset = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
return GenerateRandomString(length, charset)
|
||||
}
|
||||
|
||||
// GenerateRandomString generates a random string of the given length using the provided character set
|
||||
func GenerateRandomString(length int, charset string) (string, error) {
|
||||
|
||||
@@ -30,6 +36,10 @@ func GenerateRandomString(length int, charset string) (string, error) {
|
||||
return "", errors.New("length must be a positive integer")
|
||||
}
|
||||
|
||||
if len(charset) == 0 {
|
||||
return "", errors.New("character set must not be empty")
|
||||
}
|
||||
|
||||
// The algorithm below is adapted from https://stackoverflow.com/a/35615565
|
||||
const (
|
||||
letterIdxBits = 6 // 6 bits to represent a letter index
|
||||
|
||||
@@ -86,6 +86,20 @@ func TestGenerateRandomUnambiguousString(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateRandomUppercaseUnambiguousString(t *testing.T) {
|
||||
const length = 10
|
||||
str, err := GenerateRandomUppercaseUnambiguousString(length)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(str) != length {
|
||||
t.Errorf("Expected length %d, got %d", length, len(str))
|
||||
}
|
||||
if !regexp.MustCompile(`^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]+$`).MatchString(str) {
|
||||
t.Errorf("String contains lowercase or ambiguous characters: %s", str)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRandomString(t *testing.T) {
|
||||
t.Run("valid length returns characters from charset", func(t *testing.T) {
|
||||
const length = 20
|
||||
@@ -119,6 +133,14 @@ func TestGenerateRandomString(t *testing.T) {
|
||||
t.Error("Expected error for negative length, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty charset returns error", func(t *testing.T) {
|
||||
_, err := GenerateRandomString(10, "")
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty charset, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestCapitalizeFirstLetter(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE device_login_requests;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE device_login_requests
|
||||
(
|
||||
id UUID NOT NULL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
device_token_hash TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'denied')),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
ip_address INET NOT NULL,
|
||||
user_agent TEXT NOT NULL,
|
||||
user_id UUID REFERENCES users ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_device_login_requests_expires_at ON device_login_requests (expires_at);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE device_login_requests;
|
||||
@@ -0,0 +1,20 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE device_login_requests
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
device_token_hash TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'denied')),
|
||||
expires_at DATETIME NOT NULL,
|
||||
ip_address TEXT NOT NULL,
|
||||
user_agent TEXT NOT NULL,
|
||||
user_id TEXT REFERENCES users ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_device_login_requests_expires_at ON device_login_requests (expires_at);
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -564,5 +564,20 @@
|
||||
"select_the_permissions_this_client_may_request": "Select the permissions this client may request on behalf of the signed-in user (user-delegated access) and for itself without a user via the client credentials grant (client access).",
|
||||
"i_have_a_longer_code": "I have a longer code",
|
||||
"pkce_supported_client_title": "This client supports PKCE",
|
||||
"pkce_supported_client_description": "This client supports Proof Key for Code Exchange (PKCE). PKCE is a security feature that helps protect against certain attacks during the OAuth 2.0 authorization process. It's recommended to enable it."
|
||||
"pkce_supported_client_description": "This client supports Proof Key for Code Exchange (PKCE). PKCE is a security feature that helps protect against certain attacks during the OAuth 2.0 authorization process. It's recommended to enable it.",
|
||||
"sign_in_with_another_device": "Sign in with another device",
|
||||
"sign_in_with_another_device_description": "Scan a QR code with a device that has your passkey.",
|
||||
"creating_device_login_request": "Creating sign-in request...",
|
||||
"device_login_qr_code": "Device login QR code",
|
||||
"waiting_for_approval": "Waiting for approval",
|
||||
"remote_sign_in": "Remote sign in",
|
||||
"the_requesting_device_has_been_signed_in": "The requesting device has been signed in.",
|
||||
"the_sign_in_request_was_denied": "The sign-in request was denied.",
|
||||
"review_the_request_before_approving_it": "Review the requesting device before approving this sign in.",
|
||||
"sign_in_request": "Sign-in request",
|
||||
"only_approve_if_you_started_this_sign_in": "Only approve if you started this sign in on the requesting device.",
|
||||
"deny": "Deny",
|
||||
"approve": "Approve",
|
||||
"or": "or",
|
||||
"visit_and_enter": "Visit {url} and enter:"
|
||||
}
|
||||
|
||||
@@ -38,24 +38,13 @@
|
||||
});
|
||||
|
||||
const isDesktop = new MediaQuery('(min-width: 1024px)');
|
||||
let alternativeSignInButton = $state({
|
||||
href: '/login/alternative',
|
||||
let alternativeSignInButton = $derived({
|
||||
href:
|
||||
page.url.pathname === '/login'
|
||||
? `/login/alternative${page.url.search}`
|
||||
: `/login/alternative?redirect=${encodeURIComponent(page.url.pathname + page.url.search)}`,
|
||||
label: m.alternative_sign_in_methods()
|
||||
});
|
||||
|
||||
appConfigStore.subscribe((config) => {
|
||||
if (config.emailOneTimeAccessAsUnauthenticatedEnabled) {
|
||||
alternativeSignInButton.href = '/login/alternative';
|
||||
alternativeSignInButton.label = m.alternative_sign_in_methods();
|
||||
} else {
|
||||
alternativeSignInButton.href = '/login/alternative/code';
|
||||
alternativeSignInButton.label = m.sign_in_with_login_code();
|
||||
}
|
||||
|
||||
if (page.url.pathname != '/login') {
|
||||
alternativeSignInButton.href = `${alternativeSignInButton.href}?redirect=${encodeURIComponent(page.url.pathname + page.url.search)}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if backgroundImageExists === undefined}
|
||||
|
||||
@@ -32,9 +32,14 @@
|
||||
}
|
||||
};
|
||||
|
||||
QRCode.toCanvas(canvasEl, value, options).catch((error: Error) => {
|
||||
console.error('Error generating QR Code:', error);
|
||||
});
|
||||
QRCode.toCanvas(canvasEl, value, options)
|
||||
.then(() => {
|
||||
canvasEl?.style.removeProperty('height');
|
||||
canvasEl?.style.removeProperty('width');
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
console.error('Error generating QR Code:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
28
frontend/src/lib/services/device-login-service.ts
Normal file
28
frontend/src/lib/services/device-login-service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
DeviceLoginDecision,
|
||||
DeviceLoginExchangeResult,
|
||||
DeviceLoginRequest,
|
||||
DeviceLoginVerificationInfo
|
||||
} from '$lib/types/device-login.type';
|
||||
import APIService from './api-service';
|
||||
|
||||
export default class DeviceLoginService extends APIService {
|
||||
createRequest = async () => {
|
||||
const response = await this.api.post('/device-login/requests');
|
||||
return response.data as DeviceLoginRequest;
|
||||
};
|
||||
|
||||
exchangeRequest = async (requestId: string): Promise<DeviceLoginExchangeResult> => {
|
||||
const response = await this.api.post(`/device-login/requests/${requestId}/exchange`);
|
||||
return response.status === 202 ? null : response.data;
|
||||
};
|
||||
|
||||
inspectRequest = async (code: string) => {
|
||||
const response = await this.api.post('/device-login/verification', { code });
|
||||
return response.data as DeviceLoginVerificationInfo;
|
||||
};
|
||||
|
||||
decideRequest = async (code: string, decision: DeviceLoginDecision) => {
|
||||
await this.api.post('/device-login/verification/decision', { code, decision });
|
||||
};
|
||||
}
|
||||
21
frontend/src/lib/types/device-login.type.ts
Normal file
21
frontend/src/lib/types/device-login.type.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { User } from './user.type';
|
||||
|
||||
export type DeviceLoginRequest = {
|
||||
id: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
verificationUriComplete: string;
|
||||
expiresAt: string;
|
||||
interval: number;
|
||||
};
|
||||
|
||||
export type DeviceLoginVerificationInfo = {
|
||||
userCode: string;
|
||||
device: string;
|
||||
ipAddress?: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type DeviceLoginDecision = 'approve' | 'deny';
|
||||
|
||||
export type DeviceLoginExchangeResult = User | null;
|
||||
@@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages';
|
||||
export const eventTypes: Record<string, string> = {
|
||||
SIGN_IN: m.sign_in(),
|
||||
TOKEN_SIGN_IN: m.token_sign_in(),
|
||||
REMOTE_SIGN_IN: m.remote_sign_in(),
|
||||
CLIENT_AUTHORIZATION: m.client_authorization(),
|
||||
NEW_CLIENT_AUTHORIZATION: m.new_client_authorization(),
|
||||
ACCOUNT_CREATED: m.account_created(),
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
import ScopeList from '$lib/components/scope-list.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as InputOTP from '$lib/components/ui/input-otp';
|
||||
import { Spinner } from '$lib/components/ui/spinner';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import DeviceLoginService from '$lib/services/device-login-service';
|
||||
import OIDCService from '$lib/services/oidc-service';
|
||||
import WebAuthnService from '$lib/services/webauthn-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import type { DeviceLoginVerificationInfo } from '$lib/types/device-login.type';
|
||||
import type { OidcDeviceCodeInfo } from '$lib/types/oidc.type';
|
||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||
import { preventDefault } from '$lib/utils/event-util';
|
||||
@@ -21,17 +24,24 @@
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const deviceLoginService = new DeviceLoginService();
|
||||
const oidcService = new OIDCService();
|
||||
const webauthnService = new WebAuthnService();
|
||||
|
||||
let userCode = $state(data.code || '');
|
||||
let isLoading = $state(false);
|
||||
let deviceInfo: OidcDeviceCodeInfo | undefined = $state();
|
||||
let deviceLoginInfo: DeviceLoginVerificationInfo | undefined = $state();
|
||||
let success = $state(false);
|
||||
let deviceLoginOutcome: 'approved' | 'denied' | undefined = $state();
|
||||
let deviceLoginDecision: 'approve' | 'deny' | undefined = $state();
|
||||
let errorMessage: string | null = $state(null);
|
||||
let authorizationRequired = $state(false);
|
||||
let reauthenticationRequired = $state(false);
|
||||
let reauthenticated = $state(false);
|
||||
let normalizedUserCode = $derived(userCode.trim().toUpperCase());
|
||||
let codeComplete = $derived(normalizedUserCode.length === 8);
|
||||
let completed = $derived(success || deviceLoginOutcome !== undefined);
|
||||
|
||||
onMount(() => {
|
||||
if (data.code && $userStore) {
|
||||
@@ -40,28 +50,29 @@
|
||||
});
|
||||
|
||||
async function authorize() {
|
||||
if (!data.code && !codeComplete) return;
|
||||
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
try {
|
||||
// Get access token if not signed in
|
||||
if (!$userStore) {
|
||||
const loginOptions = await webauthnService.getLoginOptions();
|
||||
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
|
||||
const user = await webauthnService.finishLogin(authResponse);
|
||||
await userStore.setUser(user);
|
||||
await authenticateUserIfNeeded();
|
||||
|
||||
let isDeviceLoginCode = normalizedUserCode.startsWith('P');
|
||||
if (isDeviceLoginCode) {
|
||||
deviceLoginInfo = await deviceLoginService.inspectRequest(normalizedUserCode);
|
||||
return;
|
||||
}
|
||||
|
||||
const info = await oidcService.getDeviceCodeInfo(userCode);
|
||||
const info = await oidcService.getDeviceCodeInfo(normalizedUserCode);
|
||||
deviceInfo = info;
|
||||
|
||||
if (info.authorizationRequired && !authorizationRequired) {
|
||||
authorizationRequired = true;
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.reauthenticationRequired && !reauthenticationRequired && !authorizationRequired) {
|
||||
reauthenticationRequired = true;
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,16 +81,42 @@
|
||||
reauthenticated = true;
|
||||
}
|
||||
|
||||
await oidcService.verifyDeviceCode(userCode);
|
||||
|
||||
await oidcService.verifyDeviceCode(normalizedUserCode);
|
||||
success = true;
|
||||
} catch (e) {
|
||||
errorMessage = getWebauthnErrorMessage(e);
|
||||
} catch (error) {
|
||||
errorMessage = getWebauthnErrorMessage(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateUserIfNeeded() {
|
||||
if ($userStore) return;
|
||||
|
||||
const loginOptions = await webauthnService.getLoginOptions();
|
||||
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
|
||||
const user = await webauthnService.finishLogin(authResponse);
|
||||
await userStore.setUser(user);
|
||||
}
|
||||
|
||||
async function decideDeviceLogin(decision: 'approve' | 'deny') {
|
||||
isLoading = true;
|
||||
deviceLoginDecision = decision;
|
||||
errorMessage = null;
|
||||
try {
|
||||
if (decision === 'approve') {
|
||||
await reauthenticate();
|
||||
}
|
||||
await deviceLoginService.decideRequest(normalizedUserCode, decision);
|
||||
deviceLoginOutcome = decision === 'approve' ? 'approved' : 'denied';
|
||||
} catch (error) {
|
||||
errorMessage = getWebauthnErrorMessage(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
deviceLoginDecision = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function reauthenticate() {
|
||||
try {
|
||||
await webauthnService.reauthenticate();
|
||||
@@ -89,6 +126,15 @@
|
||||
await webauthnService.reauthenticate(authResponse);
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
errorMessage = null;
|
||||
if (!deviceLoginInfo) {
|
||||
deviceInfo = undefined;
|
||||
authorizationRequired = false;
|
||||
reauthenticationRequired = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -100,16 +146,52 @@
|
||||
{#if deviceInfo?.client}
|
||||
<ClientProviderImages client={deviceInfo.client} {success} error={!!errorMessage} />
|
||||
{:else}
|
||||
<LoginLogoErrorSuccessIndicator {success} error={!!errorMessage} />
|
||||
<LoginLogoErrorSuccessIndicator
|
||||
success={success || deviceLoginOutcome === 'approved'}
|
||||
error={!!errorMessage}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<h1 class="font-gloock mt-5 text-4xl font-bold">{m.authorize_device()}</h1>
|
||||
<h1 class="font-gloock mt-5 text-4xl font-bold">
|
||||
{m.authorize_device()}
|
||||
</h1>
|
||||
{#if errorMessage}
|
||||
<p class="text-muted-foreground mt-2">
|
||||
{errorMessage}. {m.please_try_again()}
|
||||
</p>
|
||||
{:else if deviceLoginOutcome === 'approved'}
|
||||
<p class="text-muted-foreground mt-2">{m.the_requesting_device_has_been_signed_in()}</p>
|
||||
{:else if deviceLoginOutcome === 'denied'}
|
||||
<p class="text-muted-foreground mt-2">{m.the_sign_in_request_was_denied()}</p>
|
||||
{:else if success}
|
||||
<p class="text-muted-foreground mt-2">{m.the_device_has_been_authorized()}</p>
|
||||
{:else if deviceLoginInfo}
|
||||
<p class="text-muted-foreground mt-2">{m.review_the_request_before_approving_it()}</p>
|
||||
<div class="w-full max-w-112.5" transition:slide={{ duration: 300 }}>
|
||||
<Card.Root class="mt-6 text-start">
|
||||
<Card.Content>
|
||||
<dl class="flex flex-col gap-4 text-sm">
|
||||
<div class="flex items-start justify-between gap-6">
|
||||
<dt class="text-muted-foreground">{m.code()}</dt>
|
||||
<dd class="font-medium">
|
||||
{deviceLoginInfo.userCode.substring(0, 4)} - {deviceLoginInfo.userCode.substring(
|
||||
4,
|
||||
8
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-6">
|
||||
<dt class="text-muted-foreground">{m.device()}</dt>
|
||||
<dd class="text-right font-medium">{deviceLoginInfo.device}</dd>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-6">
|
||||
<dt class="text-muted-foreground">{m.ip_address()}</dt>
|
||||
<dd class="font-medium">{deviceLoginInfo.ipAddress || m.unknown()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{:else if reauthenticationRequired && deviceInfo?.client}
|
||||
<p class="text-muted-foreground mt-2">
|
||||
<FormattedMessage
|
||||
@@ -120,16 +202,16 @@
|
||||
/>
|
||||
</p>
|
||||
{:else if authorizationRequired}
|
||||
<div class="w-full max-w-[450px]" transition:slide={{ duration: 300 }}>
|
||||
<div class="w-full max-w-112.5" transition:slide={{ duration: 300 }}>
|
||||
<Card.Root class="mt-6 gap-2">
|
||||
<Card.Header>
|
||||
<p class="text-muted-foreground text-start">
|
||||
<Card.Description class="text-start">
|
||||
<FormattedMessage
|
||||
m={m.client_wants_to_access_the_following_information({
|
||||
client: deviceInfo!.client.name
|
||||
})}
|
||||
/>
|
||||
</p>
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content data-testid="scopes">
|
||||
<ScopeList scopes={deviceInfo!.scope || []} scopeInfo={deviceInfo!.scopeInfo || []} />
|
||||
@@ -138,19 +220,63 @@
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground mt-2">{m.enter_code_displayed_in_previous_step()}</p>
|
||||
<form id="device-code-form" onsubmit={preventDefault(authorize)} class="w-full max-w-[450px]">
|
||||
<Input id="user-code" class="mt-7" placeholder={m.code()} bind:value={userCode} type="text" />
|
||||
<form
|
||||
id="device-code-form"
|
||||
onsubmit={preventDefault(authorize)}
|
||||
class="mt-7 flex w-full max-w-112.5 justify-center"
|
||||
>
|
||||
<InputOTP.Root
|
||||
maxlength={8}
|
||||
aria-label={m.code()}
|
||||
bind:value={userCode}
|
||||
onValueChange={(value) => (userCode = value.toUpperCase())}
|
||||
pasteTransformer={(value) => value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase()}
|
||||
>
|
||||
{#snippet children({ cells })}
|
||||
<InputOTP.Group>
|
||||
{#each cells.slice(0, 4) as cell}
|
||||
<InputOTP.Slot {cell} />
|
||||
{/each}
|
||||
</InputOTP.Group>
|
||||
<InputOTP.Separator />
|
||||
<InputOTP.Group>
|
||||
{#each cells.slice(4) as cell}
|
||||
<InputOTP.Slot {cell} />
|
||||
{/each}
|
||||
</InputOTP.Group>
|
||||
{/snippet}
|
||||
</InputOTP.Root>
|
||||
</form>
|
||||
{/if}
|
||||
{#if !success}
|
||||
<div class="mt-10 flex w-full max-w-[450px] gap-2">
|
||||
<Button href="/" class="flex-1" variant="secondary">{m.cancel()}</Button>
|
||||
{#if !errorMessage}
|
||||
<Button form="device-code-form" class="flex-1" onclick={authorize} {isLoading}
|
||||
>{m.authorize()}</Button
|
||||
{#if !completed}
|
||||
<div class="mt-10 flex w-full max-w-112.5 gap-2">
|
||||
{#if errorMessage}
|
||||
<Button class="flex-1" variant="secondary" href="/">{m.cancel()}</Button>
|
||||
<Button class="flex-1" onclick={retry}>{m.try_again()}</Button>
|
||||
{:else if deviceLoginInfo}
|
||||
<Button
|
||||
class="flex-1"
|
||||
variant="secondary"
|
||||
disabled={isLoading}
|
||||
onclick={() => decideDeviceLogin('deny')}
|
||||
>
|
||||
{#if deviceLoginDecision === 'deny'}<Spinner data-icon="inline-start" />{/if}
|
||||
{m.deny()}
|
||||
</Button>
|
||||
<Button class="flex-1" {isLoading} onclick={() => decideDeviceLogin('approve')}>
|
||||
{m.approve()}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button class="flex-1" onclick={() => (errorMessage = null)}>{m.try_again()}</Button>
|
||||
<Button href="/" class="flex-1" variant="secondary">{m.cancel()}</Button>
|
||||
<Button
|
||||
form="device-code-form"
|
||||
class="flex-1"
|
||||
disabled={isLoading || !codeComplete}
|
||||
onclick={authorize}
|
||||
>
|
||||
{#if isLoading}<Spinner data-icon="inline-start" />{/if}
|
||||
{m.authorize()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
import * as Item from '$lib/components/ui/item/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import { LucideChevronRight, LucideMail, LucideRectangleEllipsis } from '@lucide/svelte';
|
||||
import {
|
||||
LucideChevronRight,
|
||||
LucideMail,
|
||||
LucideQrCode,
|
||||
LucideRectangleEllipsis
|
||||
} from '@lucide/svelte';
|
||||
|
||||
const methods = [
|
||||
{
|
||||
@@ -13,6 +18,12 @@
|
||||
title: m.login_code(),
|
||||
description: m.enter_a_login_code_to_sign_in(),
|
||||
href: '/login/alternative/code'
|
||||
},
|
||||
{
|
||||
icon: LucideQrCode,
|
||||
title: m.sign_in_with_another_device(),
|
||||
description: m.sign_in_with_another_device_description(),
|
||||
href: '/login/alternative/device'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
140
frontend/src/routes/login/alternative/device/+page.svelte
Normal file
140
frontend/src/routes/login/alternative/device/+page.svelte
Normal file
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import Qrcode from '$lib/components/qrcode/qrcode.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Spinner } from '$lib/components/ui/spinner';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import DeviceLoginService from '$lib/services/device-login-service';
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import type { DeviceLoginRequest } from '$lib/types/device-login.type';
|
||||
import { getAxiosErrorMessage } from '$lib/utils/error-util';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { onMount } from 'svelte';
|
||||
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const deviceLoginService = new DeviceLoginService();
|
||||
|
||||
let request: DeviceLoginRequest | undefined = $state();
|
||||
let errorMessage: string | null = $state(null);
|
||||
let isStarting = $state(true);
|
||||
let pollTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
onMount(() => {
|
||||
startRequest();
|
||||
|
||||
return () => {
|
||||
clearTimers();
|
||||
};
|
||||
});
|
||||
|
||||
async function startRequest() {
|
||||
clearTimers();
|
||||
request = undefined;
|
||||
errorMessage = null;
|
||||
isStarting = true;
|
||||
|
||||
try {
|
||||
request = await deviceLoginService.createRequest();
|
||||
schedulePoll();
|
||||
} catch (error) {
|
||||
errorMessage = getAxiosErrorMessage(error);
|
||||
} finally {
|
||||
isStarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePoll() {
|
||||
if (!request) return;
|
||||
pollTimer = setTimeout(exchangeRequest, request.interval * 1000);
|
||||
}
|
||||
|
||||
async function exchangeRequest() {
|
||||
if (!request) return;
|
||||
|
||||
try {
|
||||
const user = await deviceLoginService.exchangeRequest(request.id);
|
||||
if (!user) {
|
||||
schedulePoll();
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimers();
|
||||
await userStore.setUser(user);
|
||||
await goto(data.redirect);
|
||||
} catch (error) {
|
||||
clearTimers();
|
||||
errorMessage = getAxiosErrorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
pollTimer = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.sign_in_with_another_device()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<SignInWrapper>
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator error={!!errorMessage} />
|
||||
</div>
|
||||
<h1 class="font-gloock mt-5 text-2xl font-bold sm:text-4xl">
|
||||
{m.sign_in_with_another_device()}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
{errorMessage ? errorMessage : m.sign_in_with_another_device_description()}
|
||||
</p>
|
||||
|
||||
{#if isStarting}
|
||||
<div class="mt-10 flex items-center gap-2 text-sm">
|
||||
<Spinner />
|
||||
{m.creating_device_login_request()}
|
||||
</div>
|
||||
{:else if request && !errorMessage}
|
||||
<Card.Root class="mt-8 w-full max-w-sm shrink-0">
|
||||
<Card.Content class="flex flex-col items-center gap-5">
|
||||
<Qrcode
|
||||
value={request.verificationUriComplete}
|
||||
color={mode.current === 'dark' ? '#FFFFFF' : '#000000'}
|
||||
aria-label={m.device_login_qr_code()}
|
||||
class="h-[12dvh]"
|
||||
/>
|
||||
<div class="flex w-full items-center gap-3">
|
||||
<Separator class="flex-1" />
|
||||
<span class="text-muted-foreground text-xs">{m.or()}</span>
|
||||
<Separator class="flex-1" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground text-sm mb-2">
|
||||
{m.visit_and_enter({ url: request.verificationUri })}
|
||||
</p>
|
||||
<CopyToClipboard value={request.userCode}>
|
||||
<p class="text-xl sm:text-2xl font-bold tracking-wider" data-testid="device-login-code">
|
||||
{request.userCode.substring(0, 4)}
|
||||
<span class="text-muted-foreground font-normal">-</span>
|
||||
{request.userCode.substring(4, 8)}
|
||||
</p>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
<div class="flex mt-7 md:mt-15 gap-3 w-full max-w-112.5">
|
||||
<Button class="flex-1" href={'/login/alternative' + page.url.search} variant="secondary"
|
||||
>{m.go_back()}</Button
|
||||
>
|
||||
<Button class="flex-1" isLoading={!errorMessage} onclick={startRequest}>
|
||||
{errorMessage ? m.try_again() : m.waiting_for_approval()}
|
||||
</Button>
|
||||
</div>
|
||||
</SignInWrapper>
|
||||
7
frontend/src/routes/login/alternative/device/+page.ts
Normal file
7
frontend/src/routes/login/alternative/device/+page.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
return {
|
||||
redirect: url.searchParams.get('redirect') || '/settings'
|
||||
};
|
||||
};
|
||||
116
tests/specs/device-login.spec.ts
Normal file
116
tests/specs/device-login.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { expect, test, type Browser } from '@playwright/test';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
import passkeyUtil from '../utils/passkey.util';
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await cleanupBackend();
|
||||
});
|
||||
|
||||
test('approves the QR link after requester review and fresh reauthentication', async ({
|
||||
browser,
|
||||
page
|
||||
}) => {
|
||||
const waiting = await openWaitingDevice(browser, '/settings/apps');
|
||||
const expectedLoginCodeText = `${waiting.request.userCode.substring(0, 4)} - ${waiting.request.userCode.substring(4, 8)}`;
|
||||
try {
|
||||
await (await passkeyUtil.init(page)).addPasskey();
|
||||
await expect(waiting.page.getByTestId('device-login-code')).toHaveText(expectedLoginCodeText);
|
||||
await expect(waiting.page.getByLabel('Device login QR code')).toBeVisible();
|
||||
|
||||
await page.goto(waiting.request.verificationUriComplete);
|
||||
|
||||
await expect(page.getByText(expectedLoginCodeText)).toBeVisible();
|
||||
await expect(page.getByText('Chrome', { exact: false })).toBeVisible();
|
||||
const ipAddress = page.locator('dt', { hasText: 'IP Address' }).locator('..').locator('dd');
|
||||
await expect(ipAddress).not.toHaveText('Unknown');
|
||||
|
||||
const decisionWithoutReauthentication = await page.request.post(
|
||||
'/api/device-login/verification/decision',
|
||||
{
|
||||
data: { code: waiting.request.userCode, decision: 'approve' }
|
||||
}
|
||||
);
|
||||
expect(decisionWithoutReauthentication.status()).toBe(401);
|
||||
|
||||
const reauthenticationRequest = page.waitForRequest('/api/webauthn/reauthenticate');
|
||||
await page.getByRole('button', { name: 'Approve' }).click();
|
||||
await reauthenticationRequest;
|
||||
await expect(page.getByText('The requesting device has been signed in.')).toBeVisible();
|
||||
await waiting.page.waitForURL('/settings/apps');
|
||||
} finally {
|
||||
await waiting.context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('authenticates a signed-out primary device before manual-code approval', async ({
|
||||
browser
|
||||
}) => {
|
||||
const waiting = await openWaitingDevice(browser, '/settings/account');
|
||||
const primaryContext = await browser.newContext({
|
||||
baseURL: test.info().project.use.baseURL,
|
||||
storageState: { cookies: [], origins: [] }
|
||||
});
|
||||
const primaryPage = await primaryContext.newPage();
|
||||
|
||||
try {
|
||||
await (await passkeyUtil.init(primaryPage)).addPasskey();
|
||||
await primaryPage.goto('/device');
|
||||
await primaryPage.getByRole('textbox', { name: 'Code' }).fill(waiting.request.userCode);
|
||||
await primaryPage.getByRole('button', { name: 'Authorize' }).click();
|
||||
|
||||
await primaryPage.getByRole('button', { name: 'Approve' }).click();
|
||||
await expect(primaryPage.getByText('The requesting device has been signed in.')).toBeVisible();
|
||||
await waiting.page.waitForURL('/settings/account');
|
||||
} finally {
|
||||
await primaryContext.close();
|
||||
await waiting.context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('denies a pending request without passkey reauthentication', async ({ browser, page }) => {
|
||||
const waiting = await openWaitingDevice(browser);
|
||||
|
||||
try {
|
||||
let reauthenticationCalled = false;
|
||||
await page.route('/api/webauthn/reauthenticate', async (route) => {
|
||||
reauthenticationCalled = true;
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto(waiting.request.verificationUriComplete);
|
||||
await page.getByRole('button', { name: 'Deny' }).click();
|
||||
|
||||
await expect(page.getByText('The sign-in request was denied.')).toBeVisible();
|
||||
await expect(waiting.page.getByText('Device login request was denied')).toBeVisible();
|
||||
expect(reauthenticationCalled).toBe(false);
|
||||
} finally {
|
||||
await waiting.context.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openWaitingDevice(browser: Browser, redirect = '/settings') {
|
||||
const context = await browser.newContext({
|
||||
baseURL: test.info().project.use.baseURL,
|
||||
storageState: { cookies: [], origins: [] }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const createResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === 'POST' &&
|
||||
response.url().endsWith('/api/device-login/requests')
|
||||
);
|
||||
|
||||
await page.goto(`/login/alternative?redirect=${encodeURIComponent(redirect)}`);
|
||||
await expect(page.getByText('Sign in with another device', { exact: true })).toBeVisible();
|
||||
await page.getByText('Sign in with another device', { exact: true }).click();
|
||||
|
||||
const response = await createResponse;
|
||||
expect(response.status()).toBe(201);
|
||||
const request = await response.json();
|
||||
expect(request.userCode).toMatch(/^P[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{7}$/);
|
||||
return {
|
||||
context,
|
||||
page,
|
||||
request
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user