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:
50
updates/202610260001_create_device_auth_sessions.go
Normal file
50
updates/202610260001_create_device_auth_sessions.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// deviceAuthSessionMigrations create golem15_user_device_auth_sessions, the
|
||||
// QR and short-code device authorization sessions, with the PHP plugin's
|
||||
// table and column names (the v2.1.0 to v2.7.1 migrations: short_code, a
|
||||
// nullable user_id and expires_at). The token column holds the SHA-256 hex
|
||||
// of the 64-character device token, never the token itself; jwt_jti is
|
||||
// added: the jti of the session token the device received, so a revoke can
|
||||
// blacklist it. session_id is kept for a Winter import and stays NULL.
|
||||
var deviceAuthSessionMigrations = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202610260001_create_device_auth_sessions",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`CREATE TABLE golem15_user_device_auth_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
short_code VARCHAR(9) NULL UNIQUE,
|
||||
user_id INTEGER NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'confirmed', 'used', 'expired', 'revoked')),
|
||||
expires_at TIMESTAMPTZ NULL,
|
||||
device_ip VARCHAR(255) NULL,
|
||||
device_user_agent TEXT NULL,
|
||||
device_name VARCHAR(255) NULL,
|
||||
auth_ip VARCHAR(255) NULL,
|
||||
confirmed_at TIMESTAMPTZ NULL,
|
||||
used_at TIMESTAMPTZ NULL,
|
||||
session_id VARCHAR(255) NULL,
|
||||
last_activity_at TIMESTAMPTZ NULL,
|
||||
jwt_jti VARCHAR(64) NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX golem15_user_device_auth_sessions_user_id_index ON golem15_user_device_auth_sessions (user_id);
|
||||
CREATE INDEX golem15_user_device_auth_sessions_status_index ON golem15_user_device_auth_sessions (status);
|
||||
CREATE INDEX golem15_user_device_auth_sessions_expires_at_index ON golem15_user_device_auth_sessions (expires_at);
|
||||
CREATE INDEX golem15_user_device_auth_sessions_jwt_jti_index ON golem15_user_device_auth_sessions (jwt_jti)`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec("DROP TABLE IF EXISTS golem15_user_device_auth_sessions").Error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() { Register(deviceAuthSessionMigrations...) }
|
||||
@@ -53,6 +53,7 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
||||
"202610160002_add_handle_to_users",
|
||||
"202610210001_add_users_email_verified_at",
|
||||
"202610220001_create_golem15_user_backend_links",
|
||||
"202610260001_create_device_auth_sessions",
|
||||
}
|
||||
if len(ids) != len(want) {
|
||||
t.Fatalf("history = %v", ids)
|
||||
@@ -63,9 +64,10 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Backend links, email proof, handle, OAuth identities, the three admin migrations, API tokens, user groups,
|
||||
// jwt blacklist, throttle, extend-users, then organisations.
|
||||
for range 13 {
|
||||
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email proof, handle, OAuth
|
||||
// identities, the three admin migrations, API tokens, user groups, jwt blacklist, throttle, extend-users,
|
||||
// then organisations.
|
||||
for range 14 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -54,8 +54,9 @@ func TestUserGroupsMigration(t *testing.T) {
|
||||
t.Fatalf("users_groups primary key = %q", pk)
|
||||
}
|
||||
|
||||
// Backend links, email verified at, handle, OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
|
||||
for range 9 {
|
||||
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
|
||||
// identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
|
||||
for range 10 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -47,9 +47,9 @@ func TestExtendUsersMigration(t *testing.T) {
|
||||
if !row.HasSelfSetPassword || row.IsActivated || row.MarketingConsent || row.IsOnboarded {
|
||||
t.Fatalf("defaults = %+v", row)
|
||||
}
|
||||
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups,
|
||||
// jwt blacklist, throttle, then extend-users.
|
||||
for range 12 {
|
||||
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
|
||||
// identities, the three admin migrations, API tokens, user groups, jwt blacklist, throttle, then extend-users.
|
||||
for range 13 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -88,8 +88,9 @@ func TestCreateUserThrottleMigration(t *testing.T) {
|
||||
t.Fatalf("missing column %s", col)
|
||||
}
|
||||
}
|
||||
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
|
||||
for range 11 {
|
||||
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
|
||||
// identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
|
||||
for range 12 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -120,8 +121,9 @@ func TestCreateJwtBlacklistMigration(t *testing.T) {
|
||||
t.Fatalf("missing column %s", col)
|
||||
}
|
||||
}
|
||||
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
|
||||
for range 10 {
|
||||
// Device auth sessions (golem15_user_device_auth_sessions), backend links, email verified at, handle, OAuth
|
||||
// identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
|
||||
for range 11 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user