feat: device authorization by QR or short code
PHP's device flow on golem15_user_device_auth_sessions (PHP's columns,
the token stored as its SHA-256, plus jwt_jti): POST /devices/initiate
answers a 64-character token, an XXXX-XXXX short code, the configurable
auth_url (golem15.user.device_auth.auth_url) and the expiry; a signed-in
user authorizes it with POST /devices/authorize (short_code or token,
one guarded pending-to-confirmed UPDATE); the device's first poll of
GET /devices/status/{token} after that receives a session JWT through one
guarded confirmed-to-used UPDATE, so exactly one poll gets it. Used and
revoked sessions never turn into expired; each initiate deletes a bounded
batch of stale sessions.
This commit is contained in:
@@ -1071,12 +1071,18 @@ func apiArray(ctx context.Context, app *backpack.App, user *models.User) (map[st
|
||||
}
|
||||
|
||||
func mintFor(app *backpack.App, secret string, user *models.User, issuer string) (string, error) {
|
||||
token, _, err := bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, sessionTTL(app))
|
||||
return token, err
|
||||
}
|
||||
|
||||
// sessionTTL is the lifetime of a session JWT: golem15.user.jwt.ttl
|
||||
// minutes, default 60.
|
||||
func sessionTTL(app *backpack.App) time.Duration {
|
||||
ttl := 60 * time.Minute
|
||||
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.ttl") > 0 {
|
||||
ttl = time.Duration(app.Config.Int("golem15.user.jwt.ttl")) * time.Minute
|
||||
}
|
||||
token, _, err := bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, ttl)
|
||||
return token, err
|
||||
return ttl
|
||||
}
|
||||
|
||||
// RefreshTTL is the refresh window of a session token:
|
||||
|
||||
409
controllers/devices.go
Normal file
409
controllers/devices.go
Normal file
@@ -0,0 +1,409 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// deviceTable is the device authorization session table.
|
||||
const deviceTable = "golem15_user_device_auth_sessions"
|
||||
|
||||
// deviceInsertAttempts bounds the retries of an initiate whose fresh short
|
||||
// code (or token) collided with a stored one.
|
||||
const deviceInsertAttempts = 5
|
||||
|
||||
// deviceCleanupBatch bounds the stale sessions one initiate deletes.
|
||||
const deviceCleanupBatch = 500
|
||||
|
||||
// writeDeviceUnauthorized is the PHP device controller's 401 body, which
|
||||
// differs from the session routes' {"error":true,"message":"Unauthorized"}.
|
||||
func writeDeviceUnauthorized(w http.ResponseWriter) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "Unauthorized"})
|
||||
}
|
||||
|
||||
// deviceCaller resolves the signed-in user of a device route that needs one
|
||||
// (authorize, list, revoke). It reads and checks the session token as the
|
||||
// plugin's jwt guard does (bearer header, then the session cookies; the
|
||||
// blacklist and tokens_valid_after apply) and also returns the token's jti.
|
||||
// Anything else answers PHP's 401 {"error":"Unauthorized"}.
|
||||
func deviceCaller(w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, secret string) (*models.User, string, bool) {
|
||||
principal, err := sessionGuard(app, secret).Authenticate(r)
|
||||
if err != nil || principal == nil || principal.ID == 0 {
|
||||
writeDeviceUnauthorized(w)
|
||||
return nil, "", false
|
||||
}
|
||||
var user models.User
|
||||
if err := gdb.WithContext(r.Context()).Where("id = ?", principal.ID).Take(&user).Error; err != nil {
|
||||
writeDeviceUnauthorized(w)
|
||||
return nil, "", false
|
||||
}
|
||||
jti := ""
|
||||
if raw := classes.SessionToken(r); raw != "" {
|
||||
if _, _, _, id, err := bouncer.VerifyClaims(raw, secret); err == nil {
|
||||
jti = id
|
||||
}
|
||||
}
|
||||
return &user, jti, true
|
||||
}
|
||||
|
||||
// deviceClientIP is the client address device rows record: surf.ClientIP
|
||||
// with http.trusted_proxies, as /login and /register use.
|
||||
func deviceClientIP(app *backpack.App, r *http.Request) string {
|
||||
return surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))
|
||||
}
|
||||
|
||||
// DeviceInitiate is POST /devices/initiate, called without a session by the
|
||||
// device that wants to sign in. It stores a pending session (the SHA-256 of
|
||||
// a 64-character token, a short code XXXX-XXXX, expires_at now plus
|
||||
// golem15.user.device_auth.ttl_minutes, the device's address, user agent and
|
||||
// optional device_name of at most 255 characters) and answers
|
||||
// {"success":true,"data":{"token","short_code","auth_url","expires_at"}}.
|
||||
// Each call first deletes at most 500 pending or expired sessions that
|
||||
// expired more than an hour ago.
|
||||
func DeviceInitiate(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, _, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fields, err := readFields(r)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
name := strField(fields, "device_name")
|
||||
if utf8.RuneCountInString(name) > 255 {
|
||||
writeValidation(w, map[string][]string{"device_name": {"The device name may not be greater than 255 characters."}})
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
// Stale sessions go in bounded batches, so the unauthenticated route
|
||||
// cannot grow the table without limit (a failure is only logged).
|
||||
if err := gdb.WithContext(ctx).Exec(`DELETE FROM `+deviceTable+` WHERE id IN (
|
||||
SELECT id FROM `+deviceTable+`
|
||||
WHERE status IN ('pending', 'expired') AND expires_at < NOW() - INTERVAL '1 hour'
|
||||
LIMIT ?)`, deviceCleanupBatch).Error; err != nil {
|
||||
pluginLog.Warn("golem15.user: device session cleanup", "err", err)
|
||||
}
|
||||
ip := deviceClientIP(app, r)
|
||||
ua := r.UserAgent()
|
||||
expires := time.Now().Add(classes.DeviceAuthTTL(app)).Truncate(time.Second)
|
||||
var (
|
||||
token string
|
||||
row models.DeviceAuthSession
|
||||
)
|
||||
for attempt := 0; ; attempt++ {
|
||||
token, err = classes.NewDeviceToken()
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
code, err := classes.NewDeviceShortCode()
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
row = models.DeviceAuthSession{
|
||||
TokenHash: classes.HashAPIToken(token),
|
||||
ShortCode: &code,
|
||||
Status: models.DeviceStatusPending,
|
||||
ExpiresAt: &expires,
|
||||
}
|
||||
if ip != "" {
|
||||
row.DeviceIP = &ip
|
||||
}
|
||||
if ua != "" {
|
||||
row.DeviceUserAgent = &ua
|
||||
}
|
||||
if name != "" {
|
||||
row.DeviceName = &name
|
||||
}
|
||||
err = gdb.WithContext(ctx).Create(&row).Error
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) || pgErr.Code != "23505" || attempt+1 >= deviceInsertAttempts {
|
||||
pluginLog.Error("golem15.user: store device session", "err", err)
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"data": map[string]any{
|
||||
"token": token,
|
||||
"short_code": *row.ShortCode,
|
||||
"auth_url": classes.DeviceAuthURL(app, token),
|
||||
"expires_at": wire.Time{Time: expires},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DeviceStatus is GET /devices/status/{token}, polled by the waiting
|
||||
// device. It answers 404 {"error":"Session not found"} for an unknown token
|
||||
// and PHP's flat {"success":true,"status":"expired","message":…} for a
|
||||
// pending or confirmed session past expires_at (which it marks expired).
|
||||
// The first poll after a signed-in user authorized the session receives a
|
||||
// session token: {"success":true,"data":{"status":"confirmed","user_id",
|
||||
// "message":"Device authorized - complete login","token","user"}}. One
|
||||
// guarded confirmed-to-used UPDATE decides which poll that is, so of
|
||||
// concurrent polls exactly one gets the token; the others, and every later
|
||||
// poll, answer {"success":true,"data":{"status":"used"}}. Other statuses
|
||||
// answer {"success":true,"data":{"status":…}}.
|
||||
func DeviceStatus(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, secret, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
row, found, err := deviceByToken(r, gdb, r.PathValue("token"))
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "Session not found"})
|
||||
return
|
||||
}
|
||||
if row.Status == models.DeviceStatusConfirmed && row.UserID != nil && !row.ExpiredAt(time.Now()) {
|
||||
if handOverDevice(w, r, app, gdb, secret, row) {
|
||||
return
|
||||
}
|
||||
// Another poll won the hand-over or the session just expired:
|
||||
// answer what the row says now, never a token.
|
||||
row, found, err = deviceByID(r, gdb, row.ID)
|
||||
if err != nil || !found {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeDeviceStatus(w, r, gdb, row)
|
||||
}
|
||||
}
|
||||
|
||||
// deviceByToken loads the session whose token hash matches token.
|
||||
func deviceByToken(r *http.Request, gdb *gorm.DB, token string) (models.DeviceAuthSession, bool, error) {
|
||||
var row models.DeviceAuthSession
|
||||
if token == "" {
|
||||
return row, false, nil
|
||||
}
|
||||
res := gdb.WithContext(r.Context()).Where("token = ?", classes.HashAPIToken(token)).Limit(1).Find(&row)
|
||||
return row, res.RowsAffected == 1, res.Error
|
||||
}
|
||||
|
||||
func deviceByID(r *http.Request, gdb *gorm.DB, id uint) (models.DeviceAuthSession, bool, error) {
|
||||
var row models.DeviceAuthSession
|
||||
res := gdb.WithContext(r.Context()).Where("id = ?", id).Limit(1).Find(&row)
|
||||
return row, res.RowsAffected == 1, res.Error
|
||||
}
|
||||
|
||||
// writeDeviceStatus answers a status poll that hands over no token. A
|
||||
// pending or confirmed session past expires_at is marked expired (used and
|
||||
// revoked sessions never are); an expired session answers PHP's flat
|
||||
// expired body.
|
||||
func writeDeviceStatus(w http.ResponseWriter, r *http.Request, gdb *gorm.DB, row models.DeviceAuthSession) {
|
||||
expire := row.ExpiredAt(time.Now())
|
||||
if expire || row.Status == models.DeviceStatusExpired {
|
||||
if expire {
|
||||
if err := gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+` SET status = 'expired', updated_at = NOW()
|
||||
WHERE id = ? AND status IN ('pending', 'confirmed')`, row.ID).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"status": models.DeviceStatusExpired,
|
||||
"message": "Authorization session has expired",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "data": map[string]any{"status": row.Status}})
|
||||
}
|
||||
|
||||
// handOverDevice gives the confirmed session's user a session token for the
|
||||
// device (D-12a). It reports false, having written nothing, when another
|
||||
// poll already took the session or it expired meanwhile; the token it
|
||||
// minted is then dropped unsent.
|
||||
func handOverDevice(w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, secret string, row models.DeviceAuthSession) bool {
|
||||
ctx := r.Context()
|
||||
var user models.User
|
||||
res := gdb.WithContext(ctx).Where("id = ?", *row.UserID).Limit(1).Find(&user)
|
||||
if res.Error != nil {
|
||||
writeOpaque500(w)
|
||||
return true
|
||||
}
|
||||
refused := res.RowsAffected == 0
|
||||
if !refused {
|
||||
banned, _, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, app), gdb.WithContext(ctx), []uint{user.ID})
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return true
|
||||
}
|
||||
refused = banned[user.ID]
|
||||
}
|
||||
if refused {
|
||||
// A deactivated, deleted or banned user signs no device in.
|
||||
if err := gdb.WithContext(ctx).Exec(`UPDATE `+deviceTable+` SET status = 'revoked', updated_at = NOW()
|
||||
WHERE id = ? AND status = 'confirmed'`, row.ID).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
token, jti, err := mintWithJTI(app, secret, &user, requestURL(r))
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return true
|
||||
}
|
||||
payload, err := apiArray(ctx, app, &user)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return true
|
||||
}
|
||||
res = gdb.WithContext(ctx).Exec(`UPDATE `+deviceTable+`
|
||||
SET status = 'used', used_at = NOW(), last_activity_at = NOW(), jwt_jti = ?, updated_at = NOW()
|
||||
WHERE id = ? AND status = 'confirmed' AND expires_at > NOW()`, jti, row.ID)
|
||||
if res.Error != nil {
|
||||
writeOpaque500(w)
|
||||
return true
|
||||
}
|
||||
if res.RowsAffected != 1 {
|
||||
return false
|
||||
}
|
||||
if err := FinishLogin(ctx, app, gdb, &user, deviceClientIP(app, r)); err != nil {
|
||||
pluginLog.Warn("golem15.user: finish device login", "user_id", user.ID, "err", err)
|
||||
}
|
||||
if err := FireLoginEvent(ctx, app, &user, classes.LoginSourceDevice); err != nil {
|
||||
pluginLog.Warn("golem15.user: login event listener", "user_id", user.ID, "err", err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"data": map[string]any{
|
||||
"status": models.DeviceStatusConfirmed,
|
||||
"user_id": user.ID,
|
||||
"message": "Device authorized - complete login",
|
||||
"token": token,
|
||||
"user": payload,
|
||||
},
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// DeviceAuthorize is POST /devices/authorize: a signed-in user (a valid,
|
||||
// not blacklisted session token; no second-factor check, D-12c) binds a
|
||||
// pending device session to themselves by its short_code (hyphens and
|
||||
// spaces optional, any case) or by its token (the QR code). A short_code of
|
||||
// other than 8 characters after removing hyphens answers 422 {"error":
|
||||
// "Please enter a valid 8-character code"}; an unknown, expired, used,
|
||||
// revoked or foreign session answers 400 {"error":"Invalid or expired
|
||||
// code"}; success, and repeating it for a session the caller already
|
||||
// confirmed, answers {"success":true,"message":"Device authorized
|
||||
// successfully"}. Without a session it answers 401 {"error":"Unauthorized"}.
|
||||
// The fields are read from the body only, never the query string.
|
||||
func DeviceAuthorize(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, secret, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _, ok := deviceCaller(w, r, app, gdb, secret)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fields, err := readBodyFields(r)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
invalid := func() {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Invalid or expired code"})
|
||||
}
|
||||
var (
|
||||
row models.DeviceAuthSession
|
||||
found bool
|
||||
)
|
||||
if _, has := fields["short_code"]; has {
|
||||
raw := strings.ReplaceAll(strField(fields, "short_code"), "-", "")
|
||||
if len(raw) != 8 {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "Please enter a valid 8-character code"})
|
||||
return
|
||||
}
|
||||
code, valid := classes.NormalizeDeviceShortCode(raw)
|
||||
if !valid {
|
||||
invalid()
|
||||
return
|
||||
}
|
||||
res := gdb.WithContext(r.Context()).Where("short_code = ?", code).Limit(1).Find(&row)
|
||||
if res.Error != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
found = res.RowsAffected == 1
|
||||
} else if _, has := fields["token"]; has {
|
||||
row, found, err = deviceByToken(r, gdb, strField(fields, "token"))
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
invalid()
|
||||
return
|
||||
}
|
||||
res := gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+`
|
||||
SET status = 'confirmed', user_id = ?, confirmed_at = NOW(), auth_ip = ?, updated_at = NOW()
|
||||
WHERE id = ? AND status = 'pending' AND (user_id IS NULL OR user_id = ?) AND expires_at > NOW()`,
|
||||
user.ID, nullableString(deviceClientIP(app, r)), row.ID, user.ID)
|
||||
if res.Error != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected != 1 {
|
||||
now, ok, err := deviceByID(r, gdb, row.ID)
|
||||
if err != nil || !ok {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
again := now.Status == models.DeviceStatusConfirmed && now.UserID != nil && *now.UserID == user.ID && !now.ExpiredAt(time.Now())
|
||||
if !again {
|
||||
if now.ExpiredAt(time.Now()) {
|
||||
_ = gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+` SET status = 'expired', updated_at = NOW()
|
||||
WHERE id = ? AND status IN ('pending', 'confirmed')`, now.ID).Error
|
||||
}
|
||||
invalid()
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "Device authorized successfully"})
|
||||
}
|
||||
}
|
||||
|
||||
// nullableString is nil for "", else s.
|
||||
func nullableString(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// mintWithJTI mints the session JWT as mintFor does and also returns its
|
||||
// jti, which device sessions store.
|
||||
func mintWithJTI(app *backpack.App, secret string, user *models.User, issuer string) (string, string, error) {
|
||||
return bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, sessionTTL(app))
|
||||
}
|
||||
Reference in New Issue
Block a user