mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-30 11:06:56 +00:00
1884 lines
97 KiB
Go
1884 lines
97 KiB
Go
package controller
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/i18n"
|
|
"github.com/QuantumNous/new-api/middleware"
|
|
"github.com/QuantumNous/new-api/model"
|
|
"github.com/QuantumNous/new-api/oauth"
|
|
"github.com/QuantumNous/new-api/service"
|
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
|
"github.com/fxamacker/cbor/v2"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/pquerna/otp/totp"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
func setupSecurityEnrollmentTest(t *testing.T) (*model.User, service.AuthIdentity) {
|
|
t.Helper()
|
|
require.NoError(t, i18n.Init())
|
|
gin.SetMode(gin.TestMode)
|
|
previousDB, previousLogDB := model.DB, model.LOG_DB
|
|
previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType()
|
|
previousRedis, previousSecret := common.RedisEnabled, common.SessionSecret
|
|
previousEncryption := common.PasswordLoginEncryptionEnabled
|
|
previousSettings := *system_setting.GetPasskeySettings()
|
|
dialect := os.Getenv("TEST_SECURITY_DIALECT")
|
|
if dialect == "" {
|
|
dialect = "sqlite"
|
|
}
|
|
dsn := os.Getenv("TEST_" + strings.ToUpper(dialect) + "_DSN")
|
|
var db *gorm.DB
|
|
if dialect == "sqlite" {
|
|
// Match common.SQLitePath: take the writer lock before transaction reads.
|
|
// Deferred transactions can fail to upgrade their read lock when concurrent
|
|
// proof consumption overlaps account deletion or session revocation.
|
|
var err error
|
|
db, err = gorm.Open(sqlite.Open(t.TempDir()+"/security.db?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)&_txlock=immediate"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
} else {
|
|
db, _ = newAuditTestDatabase(t, dialect, dsn)
|
|
}
|
|
logDB, _ := newAuditTestDatabase(t, dialect, dsn)
|
|
db.Logger = logger.Default.LogMode(logger.Silent)
|
|
logDB.Logger = logger.Default.LogMode(logger.Silent)
|
|
versionQuery := "SELECT VERSION()"
|
|
if dialect == "sqlite" {
|
|
versionQuery = "SELECT sqlite_version()"
|
|
}
|
|
var version string
|
|
require.NoError(t, db.Raw(versionQuery).Scan(&version).Error)
|
|
t.Logf("database: %s %s", dialect, version)
|
|
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.AuthFlow{}, &model.UserOAuthBinding{}, &model.Option{}, &model.UserAccessToken{}))
|
|
require.NoError(t, logDB.AutoMigrate(&model.AuditLog{}))
|
|
model.DB, model.LOG_DB = db, logDB
|
|
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
|
|
dbType := common.DatabaseTypeSQLite
|
|
if dialect == "mysql" {
|
|
dbType = common.DatabaseTypeMySQL
|
|
}
|
|
if dialect == "postgres" {
|
|
dbType = common.DatabaseTypePostgreSQL
|
|
}
|
|
common.SetDatabaseTypes(dbType, dbType)
|
|
common.PasswordLoginEncryptionEnabled = false
|
|
common.RedisEnabled = false
|
|
common.SessionSecret = "security-enrollment-test-secret"
|
|
*system_setting.GetPasskeySettings() = system_setting.PasskeySettings{Enabled: true, RPID: "example.com", Origins: "https://example.com", RPDisplayName: "new-api"}
|
|
t.Cleanup(func() {
|
|
model.DB, model.LOG_DB = previousDB, previousLogDB
|
|
common.SetDatabaseTypes(previousMain, previousLog)
|
|
common.RedisEnabled, common.SessionSecret = previousRedis, previousSecret
|
|
common.PasswordLoginEncryptionEnabled = previousEncryption
|
|
*system_setting.GetPasskeySettings() = previousSettings
|
|
connection, err := db.DB()
|
|
if err == nil {
|
|
_ = connection.Close()
|
|
}
|
|
})
|
|
password, err := common.Password2Hash("enrollment-password")
|
|
require.NoError(t, err)
|
|
user := &model.User{Username: "enrollment-user", Password: password, Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1}
|
|
require.NoError(t, db.Create(user).Error)
|
|
require.NoError(t, model.PublishUserAuthCache(user.Id))
|
|
bundle, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "enrollment-test")
|
|
require.NoError(t, err)
|
|
identity, err := service.ParseAccessToken(bundle.AccessToken)
|
|
require.NoError(t, err)
|
|
return user, identity
|
|
}
|
|
|
|
func securityEnrollmentRequest(method, path, body, proof string, identity service.AuthIdentity, handler gin.HandlerFunc) *httptest.ResponseRecorder {
|
|
response := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(response)
|
|
c.Request = httptest.NewRequest(method, path, strings.NewReader(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
c.Request.Header.Set("X-Security-Proof", proof)
|
|
c.Set("id", identity.UserID)
|
|
c.Set("role", common.RoleCommonUser)
|
|
c.Set("session_id", identity.SessionID)
|
|
c.Set("auth_version", identity.UserAuthVersion)
|
|
c.Set("session_version", identity.SessionVersion)
|
|
handler(c)
|
|
return response
|
|
}
|
|
|
|
func issueSecurityEnrollmentProof(t *testing.T, identity service.AuthIdentity, operation service.VerificationOperation, method string) string {
|
|
t.Helper()
|
|
binding, err := service.BindVerificationOperation(operation)
|
|
require.NoError(t, err)
|
|
proof, _, err := service.IssueSecurityProof(identity, method, binding)
|
|
require.NoError(t, err)
|
|
return proof
|
|
}
|
|
|
|
// withAccessTokenID sets the :id route parameter the way the router would.
|
|
func withAccessTokenID(handler gin.HandlerFunc, id int) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(id)}}
|
|
handler(c)
|
|
}
|
|
}
|
|
|
|
type accessTokenEndpoint struct {
|
|
name, method, path, body string
|
|
scope string
|
|
context []byte
|
|
handler gin.HandlerFunc
|
|
}
|
|
|
|
// accessTokenMutationEndpoints lists every proof-guarded access token
|
|
// operation for one scoped token of the test user.
|
|
func accessTokenMutationEndpoints(tokenID int) []accessTokenEndpoint {
|
|
return []accessTokenEndpoint{
|
|
{"create", "POST", "/api/user/access_tokens", `{"name":"ci","scopes":["profile:read"],"expires_at":0}`, service.VerificationScopeAccessTokenGenerate, []byte(`{"scopes":["profile:read"],"expires_at":0}`), CreateAccessToken},
|
|
{"update", "PATCH", fmt.Sprintf("/api/user/access_tokens/%d", tokenID), `{"name":"ci","scopes":["usage:read"]}`, service.VerificationScopeAccessTokenUpdate, []byte(fmt.Sprintf(`{"token_id":%d,"scopes":["usage:read"]}`, tokenID)), withAccessTokenID(UpdateAccessToken, tokenID)},
|
|
{"delete", "DELETE", fmt.Sprintf("/api/user/access_tokens/%d", tokenID), "", service.VerificationScopeAccessTokenRevoke, []byte(fmt.Sprintf(`{"token_id":%d}`, tokenID)), withAccessTokenID(DeleteAccessToken, tokenID)},
|
|
{"legacy", "DELETE", "/api/user/access_tokens/legacy", "", service.VerificationScopeAccessTokenRevoke, []byte(`{"legacy":true}`), RevokeLegacyAccessToken},
|
|
}
|
|
}
|
|
|
|
// assertAccessTokensUnchanged checks that the seeded scoped token keeps its
|
|
// name and grant and that the legacy token still authenticates.
|
|
func assertAccessTokensUnchanged(t *testing.T, userID int, scopedID int, legacy string) {
|
|
t.Helper()
|
|
tokens, err := model.ListUserAccessTokens(userID)
|
|
require.NoError(t, err)
|
|
require.Len(t, tokens, 1)
|
|
assert.Equal(t, scopedID, tokens[0].Id)
|
|
assert.Equal(t, "test token", tokens[0].Name)
|
|
assert.Equal(t, []string{"profile:read"}, tokens[0].GetScopes())
|
|
stored, err := model.ValidateAccessToken(legacy)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, stored)
|
|
assert.Equal(t, userID, stored.Id)
|
|
}
|
|
|
|
func TestSecurityEnrollmentAccessTokenRequiresProofBeforeMutation(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
require.NoError(t, model.DB.Model(user).Update("access_token", "existing-system-token").Error)
|
|
_, scoped := createScopedAccessToken(t, user.Id, 0, "profile:read")
|
|
for _, endpoint := range accessTokenMutationEndpoints(scoped.Id) {
|
|
t.Run(endpoint.name, func(t *testing.T) {
|
|
response := securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, "", identity, endpoint.handler)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
assert.Equal(t, http.StatusForbidden, response.Code)
|
|
assert.False(t, body.Success)
|
|
assert.Equal(t, "SECURITY_PROOF_REQUIRED", body.Code)
|
|
assertAccessTokensUnchanged(t, user.Id, scoped.Id, "existing-system-token")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityEnrollmentAccessTokenMethodPolicy(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name, method string
|
|
password, passkey, twoFA, locked bool
|
|
disabledPasskey, oauth, wechat bool
|
|
available bool
|
|
}{
|
|
{name: "password", method: "password", password: true, oauth: true, available: true},
|
|
{name: "existing passkey", method: "passkey", password: true, passkey: true, available: true},
|
|
{name: "existing twofa", method: "2fa", password: true, passkey: true, twoFA: true, available: true},
|
|
{name: "locked twofa blocks fallback", method: "2fa", password: true, twoFA: true, locked: true},
|
|
{name: "disabled passkey blocks fallback", method: "passkey", password: true, passkey: true, disabledPasskey: true},
|
|
{name: "disabled passkey does not block password", method: "password", password: true, disabledPasskey: true, available: true},
|
|
{name: "linked oauth", method: "oauth", oauth: true, available: true},
|
|
{name: "wechat session cannot manage tokens", method: "oauth", wechat: true},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
if !test.password {
|
|
require.NoError(t, model.DB.Model(user).Update("password", "").Error)
|
|
}
|
|
if test.passkey {
|
|
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "existing-key", PublicKey: "public-key"}).Error)
|
|
}
|
|
if test.twoFA {
|
|
twoFA := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
|
|
if test.locked {
|
|
until := time.Now().Add(time.Minute)
|
|
twoFA.LockedUntil = &until
|
|
}
|
|
require.NoError(t, model.DB.Create(twoFA).Error)
|
|
}
|
|
if test.oauth {
|
|
require.NoError(t, model.DB.Model(user).Update("github_id", "linked-user").Error)
|
|
oauth.Register("access-token-oauth", &enrollmentOAuthProvider{externalID: "linked-user"})
|
|
t.Cleanup(func() { oauth.Unregister("access-token-oauth") })
|
|
}
|
|
if test.wechat {
|
|
require.NoError(t, model.DB.Model(user).Update("wechat_id", "wechat-user").Error)
|
|
}
|
|
system_setting.GetPasskeySettings().Enabled = !test.disabledPasskey
|
|
passwordScope := service.VerificationScopePasswordChange
|
|
if !test.password {
|
|
passwordScope = service.VerificationScopePasswordSet
|
|
}
|
|
for _, scope := range []string{service.VerificationScopeAccessTokenGenerate, service.VerificationScopeAccessTokenUpdate, service.VerificationScopeAccessTokenRevoke,
|
|
service.VerificationScopeAccountBind, service.VerificationScopeAccountUnbind, passwordScope} {
|
|
requirements, err := service.GetVerificationRequirements(identity, scope)
|
|
require.NoError(t, err)
|
|
count := 1
|
|
if test.twoFA && test.passkey {
|
|
count = 2
|
|
}
|
|
require.Len(t, requirements.Methods, count)
|
|
assert.Equal(t, test.method, requirements.Methods[0].Method)
|
|
assert.Equal(t, test.available, requirements.Methods[0].Available)
|
|
if count == 2 {
|
|
assert.Equal(t, service.VerificationMethodOption{Method: "passkey", Available: true}, requirements.Methods[1])
|
|
}
|
|
if test.wechat {
|
|
input := service.VerificationInput{Scope: scope, Method: "session"}
|
|
switch scope {
|
|
case service.VerificationScopeAccountBind:
|
|
input.Context = []byte(`{"provider":"email","email":"new@example.com"}`)
|
|
case service.VerificationScopeAccountUnbind:
|
|
input.Context = []byte(`{"provider_id":1}`)
|
|
case service.VerificationScopeAccessTokenGenerate:
|
|
input.Context = []byte(`{"scopes":["profile:read"],"expires_at":0}`)
|
|
case service.VerificationScopeAccessTokenUpdate:
|
|
input.Context = []byte(`{"token_id":1,"scopes":["profile:read"]}`)
|
|
case service.VerificationScopeAccessTokenRevoke:
|
|
input.Context = []byte(`{"token_id":1}`)
|
|
}
|
|
_, err := service.VerifySecurityInput(identity, input)
|
|
assert.ErrorIs(t, err, service.ErrProofMethod)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityEnrollmentAccessTokenLifecycleConsumesProofs(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
require.NoError(t, model.DB.Model(user).Update("access_token", "previous-token").Error)
|
|
createBody := `{"name":"ci","scopes":["profile:read"],"expires_at":0}`
|
|
var created []string
|
|
for range 2 {
|
|
proof, err := service.VerifySecurityInput(identity, service.VerificationInput{
|
|
Scope: service.VerificationScopeAccessTokenGenerate, Context: []byte(`{"scopes":["profile:read"],"expires_at":0}`), Method: "password", Password: "enrollment-password",
|
|
})
|
|
require.NoError(t, err)
|
|
wrongScope := securityEnrollmentRequest("DELETE", "/api/user/access_tokens/legacy", "", proof.ProofToken, identity, RevokeLegacyAccessToken)
|
|
assert.Contains(t, wrongScope.Body.String(), `"code":"SECURITY_PROOF_SCOPE_MISMATCH"`)
|
|
response := securityEnrollmentRequest("POST", "/api/user/access_tokens", createBody, proof.ProofToken, identity, CreateAccessToken)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
var result struct {
|
|
Token string `json:"token"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(body.Data, &result))
|
|
require.True(t, strings.HasPrefix(result.Token, model.AccessTokenPrefix))
|
|
stored, err := model.FindUserAccessTokenByHash(model.AccessTokenFingerprint(result.Token))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, stored)
|
|
response = securityEnrollmentRequest("POST", "/api/user/access_tokens", createBody, proof.ProofToken, identity, CreateAccessToken)
|
|
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
|
|
created = append(created, result.Token)
|
|
}
|
|
legacyUser, err := model.ValidateAccessToken("previous-token")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, legacyUser, "creating scoped tokens leaves the legacy token alone")
|
|
tokens, err := model.ListUserAccessTokens(user.Id)
|
|
require.NoError(t, err)
|
|
require.Len(t, tokens, 2)
|
|
first, second := tokens[0], tokens[1]
|
|
|
|
// A revoke proof is bound to one token ID.
|
|
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: []byte(fmt.Sprintf(`{"token_id":%d}`, first.Id))}, "password")
|
|
response := securityEnrollmentRequest("DELETE", "/api/user/access_tokens/x", "", proof, identity, withAccessTokenID(DeleteAccessToken, second.Id))
|
|
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONTEXT_MISMATCH"`)
|
|
response = securityEnrollmentRequest("DELETE", "/api/user/access_tokens/x", "", proof, identity, withAccessTokenID(DeleteAccessToken, first.Id))
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
deleted, err := model.FindUserAccessTokenByHash(first.TokenHash)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, deleted)
|
|
kept, err := model.FindUserAccessTokenByHash(second.TokenHash)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, kept)
|
|
|
|
legacyProof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: []byte(`{"legacy":true}`)}, "password")
|
|
response = securityEnrollmentRequest("DELETE", "/api/user/access_tokens/legacy", "", legacyProof, identity, RevokeLegacyAccessToken)
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
legacyUser, err = model.ValidateAccessToken("previous-token")
|
|
require.NoError(t, err)
|
|
assert.Nil(t, legacyUser)
|
|
response = securityEnrollmentRequest("DELETE", "/api/user/access_tokens/legacy", "", legacyProof, identity, RevokeLegacyAccessToken)
|
|
assert.Equal(t, http.StatusNotFound, response.Code, "there is nothing left to revoke")
|
|
|
|
var audits []model.AuditLog
|
|
require.NoError(t, model.LOG_DB.Order("id").Find(&audits).Error)
|
|
actions := make([]string, 0, len(audits))
|
|
for _, audit := range audits {
|
|
actions = append(actions, audit.Action)
|
|
}
|
|
assert.Equal(t, []string{"access_token.generate", "access_token.generate", "access_token.revoke", "access_token.revoke"}, actions)
|
|
encoded, err := common.Marshal(audits)
|
|
require.NoError(t, err)
|
|
for _, secret := range append(created, "previous-token", proof, legacyProof) {
|
|
assert.NotContains(t, string(encoded), secret)
|
|
}
|
|
}
|
|
|
|
func TestSecurityEnrollmentAccessTokenRejectsInvalidProofs(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
require.NoError(t, model.DB.Model(user).Update("access_token", "unchanged-token").Error)
|
|
_, scoped := createScopedAccessToken(t, user.Id, 0, "profile:read")
|
|
for _, endpoint := range accessTokenMutationEndpoints(scoped.Id) {
|
|
for _, failure := range []string{"session", "user version", "expired"} {
|
|
t.Run(endpoint.name+"/"+failure, func(t *testing.T) {
|
|
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope, Context: endpoint.context}, "password")
|
|
requestIdentity := identity
|
|
code := "SECURITY_PROOF_INVALID"
|
|
switch failure {
|
|
case "session":
|
|
requestIdentity.SessionID = "other-session"
|
|
case "user version":
|
|
requestIdentity.UserAuthVersion++
|
|
case "expired":
|
|
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Update("expires_at", time.Now().Add(-time.Minute)).Error)
|
|
code = "SECURITY_PROOF_EXPIRED"
|
|
}
|
|
response := securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, proof, requestIdentity, endpoint.handler)
|
|
assert.Equal(t, http.StatusForbidden, response.Code)
|
|
assert.Contains(t, response.Body.String(), code)
|
|
assertAccessTokensUnchanged(t, user.Id, scoped.Id, "unchanged-token")
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSecurityEnrollmentAccessTokenFailureDoesNotRestoreProof(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
require.NoError(t, model.DB.Model(user).Update("access_token", "unchanged-token").Error)
|
|
_, scoped := createScopedAccessToken(t, user.Id, 0, "profile:read")
|
|
failWrite := func(table string) func(tx *gorm.DB) {
|
|
return func(tx *gorm.DB) {
|
|
if tx.Statement.Table == table {
|
|
tx.AddError(errors.New("private database failure"))
|
|
}
|
|
}
|
|
}
|
|
require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("access_token_create_failure", failWrite("user_access_tokens")))
|
|
require.NoError(t, model.DB.Callback().Delete().Before("gorm:delete").Register("access_token_delete_failure", failWrite("user_access_tokens")))
|
|
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("legacy_token_update_failure", failWrite("users")))
|
|
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("access_token_update_failure", failWrite("user_access_tokens")))
|
|
for _, endpoint := range accessTokenMutationEndpoints(scoped.Id) {
|
|
t.Run(endpoint.name, func(t *testing.T) {
|
|
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope, Context: endpoint.context}, "password")
|
|
response := securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, proof, identity, endpoint.handler)
|
|
assert.Equal(t, http.StatusInternalServerError, response.Code)
|
|
assert.NotContains(t, response.Body.String(), "private database")
|
|
response = securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, proof, identity, endpoint.handler)
|
|
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
|
|
})
|
|
}
|
|
assertAccessTokensUnchanged(t, user.Id, scoped.Id, "unchanged-token")
|
|
}
|
|
|
|
func decodeSecurityEnrollmentResponse(t *testing.T, response *httptest.ResponseRecorder) securityEnrollmentResponse {
|
|
t.Helper()
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body), response.Body.String())
|
|
return body
|
|
}
|
|
|
|
// A scoped token completes step-up on its own pat:<id> identity. The change it
|
|
// makes logs out every browser session and leaves the token usable.
|
|
func TestSecurityEnrollmentAccessTokenStepUpDisablesTwoFA(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
|
|
browser, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "browser")
|
|
require.NoError(t, err)
|
|
raw, token := createScopedAccessToken(t, user.Id, 0, "account_security:write", "profile:read")
|
|
router := newAccessTokenTestRouter()
|
|
|
|
response := accessTokenRequest(router, http.MethodGet, "/api/verify/methods?scope=2fa.disable", raw, "", "")
|
|
body := decodeSecurityEnrollmentResponse(t, response)
|
|
require.True(t, body.Success, body.Message)
|
|
var requirements service.VerificationRequirements
|
|
require.NoError(t, common.Unmarshal(body.Data, &requirements))
|
|
assert.Equal(t, []service.VerificationMethodOption{{Method: service.VerificationMethodTwoFA, Available: true}}, requirements.Methods)
|
|
|
|
code, err := totp.GenerateCode("JBSWY3DPEHPK3PXP", time.Now())
|
|
require.NoError(t, err)
|
|
response = accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", `{"scope":"2fa.disable","method":"2fa","code":"`+code+`"}`)
|
|
body = decodeSecurityEnrollmentResponse(t, response)
|
|
require.True(t, body.Success, body.Message)
|
|
var proof service.SecurityProof
|
|
require.NoError(t, common.Unmarshal(body.Data, &proof))
|
|
response = accessTokenRequest(router, http.MethodPost, "/api/user/2fa/disable", raw, proof.ProofToken, `{}`)
|
|
body = decodeSecurityEnrollmentResponse(t, response)
|
|
require.True(t, body.Success, body.Message)
|
|
assert.JSONEq(t, `{}`, string(body.Data), "a token-initiated change issues no browser session")
|
|
|
|
twoFA, err := model.GetTwoFAByUserId(user.Id)
|
|
require.NoError(t, err)
|
|
assert.False(t, twoFA != nil && twoFA.IsEnabled)
|
|
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, http.MethodGet, "/api/user/self", browser.AccessToken, "", "").Code)
|
|
session, err := model.GetUserSessionBySID(browser.Session.SID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, model.UserSessionStatusRevoked, session.Status)
|
|
assert.Equal(t, http.StatusOK, accessTokenRequest(router, http.MethodGet, "/api/user/self", raw, "", "").Code)
|
|
|
|
var audits []model.AuditLog
|
|
require.NoError(t, model.LOG_DB.Where("action IN ?", []string{"user.security_verify", "user.2fa_disable_self"}).Order("id").Find(&audits).Error)
|
|
require.Len(t, audits, 2)
|
|
encoded, err := common.Marshal(audits)
|
|
require.NoError(t, err)
|
|
for _, secret := range []string{raw, proof.ProofToken, code} {
|
|
assert.NotContains(t, string(encoded), secret)
|
|
}
|
|
for _, audit := range audits {
|
|
assert.Equal(t, "access_token", audit.AuthMethod)
|
|
require.NotNil(t, audit.Other.Op)
|
|
params, err := common.Marshal(audit.Other.Op.Params)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(params), `"token_ref":"`+token.TokenHash+`"`)
|
|
}
|
|
}
|
|
|
|
func TestSecurityEnrollmentAccessTokenStepUpBoundaries(t *testing.T) {
|
|
t.Run("token scope gates verification", func(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
readOnly, _ := createScopedAccessToken(t, user.Id, 0, "profile:read")
|
|
full, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write", "profile:read", "user:write")
|
|
router := newAccessTokenTestRouter()
|
|
for _, request := range []struct {
|
|
name, credential, path, body string
|
|
}{
|
|
{"methods without account security", readOnly, "/api/verify/methods?scope=2fa.setup", ""},
|
|
{"verify without account security", readOnly, "/api/verify", `{"scope":"2fa.setup","method":"password","password":"enrollment-password"}`},
|
|
{"token creation", full, "/api/verify", `{"scope":"access_token.generate","method":"password","password":"enrollment-password","context":{"scopes":["profile:read"],"expires_at":0}}`},
|
|
{"token grant change", full, "/api/verify", `{"scope":"access_token.update","method":"password","password":"enrollment-password","context":{"token_id":1,"scopes":["profile:read"]}}`},
|
|
{"token revocation", full, "/api/verify", `{"scope":"access_token.revoke","method":"password","password":"enrollment-password","context":{"legacy":true}}`},
|
|
} {
|
|
method := http.MethodPost
|
|
if request.body == "" {
|
|
method = http.MethodGet
|
|
}
|
|
response := accessTokenRequest(router, method, request.path, request.credential, "", request.body)
|
|
assert.Equal(t, http.StatusForbidden, response.Code, request.name)
|
|
assert.Equal(t, "SECURITY_ACTION_FORBIDDEN", decodeSecurityEnrollmentResponse(t, response).Code, request.name)
|
|
}
|
|
var proofs int64
|
|
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Count(&proofs).Error)
|
|
assert.Zero(t, proofs)
|
|
})
|
|
|
|
t.Run("OAuth-only accounts have no method for a token", func(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "github_id": "linked-user"}).Error)
|
|
oauth.Register("enrollment-oauth", &enrollmentOAuthProvider{externalID: "linked-user"})
|
|
t.Cleanup(func() { oauth.Unregister("enrollment-oauth") })
|
|
raw, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write")
|
|
router := newAccessTokenTestRouter()
|
|
body := decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodGet, "/api/verify/methods?scope=2fa.setup", raw, "", ""))
|
|
require.True(t, body.Success, body.Message)
|
|
var requirements service.VerificationRequirements
|
|
require.NoError(t, common.Unmarshal(body.Data, &requirements))
|
|
require.Len(t, requirements.Methods, 1)
|
|
assert.Equal(t, service.VerificationMethodOAuth, requirements.Methods[0].Method)
|
|
assert.False(t, requirements.Methods[0].Available)
|
|
assert.Empty(t, requirements.OAuthProviders)
|
|
body = decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", `{"scope":"2fa.setup","method":"oauth"}`))
|
|
assert.Equal(t, "SECURITY_METHOD_UNAVAILABLE", body.Code)
|
|
// The same account still verifies through OAuth in a browser.
|
|
browser, err := service.GetVerificationRequirements(identity, service.VerificationScopeTwoFASetup)
|
|
require.NoError(t, err)
|
|
assert.True(t, browser.Methods[0].Available)
|
|
})
|
|
|
|
t.Run("a proof stays with the token that obtained it", func(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
browser, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "browser")
|
|
require.NoError(t, err)
|
|
first, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write")
|
|
second, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write")
|
|
router := newAccessTokenTestRouter()
|
|
body := decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/verify", first, "", `{"scope":"2fa.setup","method":"password","password":"enrollment-password"}`))
|
|
require.True(t, body.Success, body.Message)
|
|
var proof service.SecurityProof
|
|
require.NoError(t, common.Unmarshal(body.Data, &proof))
|
|
for _, credential := range []string{second, browser.AccessToken} {
|
|
response := accessTokenRequest(router, http.MethodPost, "/api/user/2fa/setup", credential, proof.ProofToken, `{}`)
|
|
assert.Equal(t, "SECURITY_PROOF_INVALID", decodeSecurityEnrollmentResponse(t, response).Code)
|
|
}
|
|
body = decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/user/2fa/setup", first, proof.ProofToken, `{}`))
|
|
assert.True(t, body.Success, "rejected holders do not burn the proof: %s", body.Message)
|
|
})
|
|
|
|
t.Run("revoking the token voids its proof", func(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
raw, token := createScopedAccessToken(t, user.Id, 0, "account_security:write")
|
|
router := newAccessTokenTestRouter()
|
|
body := decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", `{"scope":"2fa.setup","method":"password","password":"enrollment-password"}`))
|
|
require.True(t, body.Success, body.Message)
|
|
var proof service.SecurityProof
|
|
require.NoError(t, common.Unmarshal(body.Data, &proof))
|
|
_, err := model.DeleteUserAccessToken(user.Id, token.Id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, http.MethodPost, "/api/user/2fa/setup", raw, proof.ProofToken, `{}`).Code)
|
|
// A request that authenticated just before the revocation cannot spend it either.
|
|
identity := service.AuthIdentity{UserID: user.Id, SessionID: model.AccessTokenSessionID(token.Id), UserAuthVersion: user.AuthVersion, SessionVersion: model.AccessTokenSessionVersion}
|
|
_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: service.VerificationScopeTwoFASetup})
|
|
assert.Error(t, err)
|
|
var consumed int64
|
|
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ? AND consumed_at IS NOT NULL", model.AuthFlowPurposeSecurityProof).Count(&consumed).Error)
|
|
assert.Zero(t, consumed)
|
|
})
|
|
}
|
|
|
|
func authorizeSecurityEnrollment(t *testing.T, identity service.AuthIdentity) *model.AuthFlowAuthorization {
|
|
t.Helper()
|
|
operation := service.VerificationOperation{Scope: service.VerificationScopeTwoFASetup}
|
|
proof := issueSecurityEnrollmentProof(t, identity, operation, service.VerificationMethodPassword)
|
|
authorization, err := service.ConsumeOperationProof(proof, identity, operation)
|
|
require.NoError(t, err)
|
|
return authorization
|
|
}
|
|
|
|
// securityPasskeyResponse acts as a software authenticator at the browser boundary.
|
|
// The handlers still validate the real WebAuthn challenge, origin and signature.
|
|
func securityPasskeyResponse(t *testing.T, key *ecdsa.PrivateKey, challenge string, registration bool, counter uint32, userVerified ...bool) json.RawMessage {
|
|
t.Helper()
|
|
ceremony := "webauthn.get"
|
|
if registration {
|
|
ceremony = "webauthn.create"
|
|
}
|
|
clientData, err := common.Marshal(map[string]any{"type": ceremony, "challenge": challenge, "origin": "https://example.com"})
|
|
require.NoError(t, err)
|
|
credentialID := sha256.Sum256(elliptic.Marshal(key.Curve, key.X, key.Y))
|
|
rpIDHash := sha256.Sum256([]byte("example.com"))
|
|
authData := append([]byte{}, rpIDHash[:]...)
|
|
response := map[string]any{"clientDataJSON": base64.RawURLEncoding.EncodeToString(clientData)}
|
|
if registration {
|
|
flags := byte(0x45) // user present, user verified, attested credential
|
|
if len(userVerified) > 0 && !userVerified[0] {
|
|
flags = 0x41
|
|
}
|
|
authData = append(authData, flags)
|
|
authData = append(authData, make([]byte, 4+16)...)
|
|
authData = binary.BigEndian.AppendUint16(authData, uint16(len(credentialID)))
|
|
authData = append(authData, credentialID[:]...)
|
|
publicKey, err := cbor.Marshal(map[int]any{
|
|
1: 2, 3: -7, -1: 1, -2: key.X.FillBytes(make([]byte, 32)), -3: key.Y.FillBytes(make([]byte, 32)),
|
|
})
|
|
require.NoError(t, err)
|
|
authData = append(authData, publicKey...)
|
|
attestation, err := cbor.Marshal(map[string]any{"fmt": "none", "authData": authData, "attStmt": map[string]any{}})
|
|
require.NoError(t, err)
|
|
response["attestationObject"] = base64.RawURLEncoding.EncodeToString(attestation)
|
|
} else {
|
|
flags := byte(0x05) // user present and verified
|
|
if len(userVerified) > 0 && !userVerified[0] {
|
|
flags = 0x01
|
|
}
|
|
authData = append(authData, flags)
|
|
authData = binary.BigEndian.AppendUint32(authData, counter)
|
|
clientHash := sha256.Sum256(clientData)
|
|
signedData := append(append([]byte{}, authData...), clientHash[:]...)
|
|
signedHash := sha256.Sum256(signedData)
|
|
signature, err := ecdsa.SignASN1(rand.Reader, key, signedHash[:])
|
|
require.NoError(t, err)
|
|
response["authenticatorData"] = base64.RawURLEncoding.EncodeToString(authData)
|
|
response["signature"] = base64.RawURLEncoding.EncodeToString(signature)
|
|
}
|
|
id := base64.RawURLEncoding.EncodeToString(credentialID[:])
|
|
credential, err := common.Marshal(map[string]any{"id": id, "rawId": id, "type": "public-key", "response": response})
|
|
require.NoError(t, err)
|
|
return credential
|
|
}
|
|
|
|
func TestSecurityEnrollmentRejectsMissingProofBeforeCreatingCredentials(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
for _, test := range []struct {
|
|
path string
|
|
handler gin.HandlerFunc
|
|
}{
|
|
{"/api/user/2fa/setup", Setup2FA},
|
|
{"/api/user/passkey/register/begin", PasskeyRegisterBegin},
|
|
} {
|
|
t.Run(test.path, func(t *testing.T) {
|
|
response := securityEnrollmentRequest(http.MethodPost, test.path, `{}`, "", identity, test.handler)
|
|
assert.Equal(t, http.StatusForbidden, response.Code)
|
|
assert.Contains(t, response.Body.String(), "SECURITY_PROOF_REQUIRED")
|
|
assert.NotContains(t, response.Body.String(), "qr_code_data")
|
|
})
|
|
}
|
|
pending, err := model.GetTwoFAByUserId(user.Id)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, pending)
|
|
}
|
|
|
|
type securityEnrollmentResponse struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
MessageKey string `json:"message_key"`
|
|
Code string `json:"code"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
|
|
func TestSecurityEnrollmentMethodPolicy(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
password, passkey, twoFA, locked, disabledPasskey bool
|
|
method string
|
|
available bool
|
|
}{
|
|
{name: "first factor uses password", password: true, method: "password", available: true},
|
|
{name: "existing passkey takes precedence", password: true, passkey: true, method: "passkey", available: true},
|
|
{name: "twofa and passkey are alternatives", password: true, passkey: true, twoFA: true, method: "2fa", available: true},
|
|
{name: "locked twofa permits passkey", password: true, passkey: true, twoFA: true, locked: true, method: "2fa"},
|
|
{name: "disabled passkey does not fall back", password: true, passkey: true, disabledPasskey: true, method: "passkey"},
|
|
{name: "disabled registration does not request a password", password: true, disabledPasskey: true, method: "password"},
|
|
{name: "passwordless account without providers is unavailable", method: "oauth"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
if !test.password {
|
|
require.NoError(t, model.DB.Model(user).Update("password", "").Error)
|
|
}
|
|
if test.passkey {
|
|
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "enrolled-passkey", PublicKey: "public-key"}).Error)
|
|
}
|
|
if test.twoFA {
|
|
twoFA := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
|
|
if test.locked {
|
|
until := time.Now().Add(time.Minute)
|
|
twoFA.LockedUntil = &until
|
|
}
|
|
require.NoError(t, model.DB.Create(twoFA).Error)
|
|
}
|
|
system_setting.GetPasskeySettings().Enabled = !test.disabledPasskey
|
|
requirements, err := service.GetVerificationRequirements(identity, "passkey.register")
|
|
require.NoError(t, err)
|
|
count := 1
|
|
if test.twoFA && test.passkey {
|
|
count = 2
|
|
}
|
|
require.Len(t, requirements.Methods, count)
|
|
assert.Equal(t, test.method, requirements.Methods[0].Method)
|
|
assert.Equal(t, test.available, requirements.Methods[0].Available)
|
|
if count == 2 {
|
|
assert.Equal(t, service.VerificationMethodOption{Method: "passkey", Available: true}, requirements.Methods[1])
|
|
}
|
|
if test.passkey && !test.twoFA {
|
|
requirements, err = service.GetVerificationRequirements(identity, "2fa.setup")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "passkey", requirements.Methods[0].Method)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityEnrollmentPasswordProofIsBoundToSessionAndAction(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
response := securityEnrollmentRequest("POST", "/api/verify", `{"method":"password","scope":"2fa.setup","password":"wrong"}`, "", identity, UniversalVerify)
|
|
assert.NotContains(t, response.Body.String(), "proof_token")
|
|
response = securityEnrollmentRequest("POST", "/api/verify", `{"method":"password","scope":"2fa.setup","password":"enrollment-password"}`, "", identity, UniversalVerify)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
var proof service.SecurityProof
|
|
require.NoError(t, common.Unmarshal(body.Data, &proof))
|
|
_, err := service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "passkey.register"})
|
|
assert.ErrorIs(t, err, service.ErrProofScope)
|
|
other := identity
|
|
other.SessionID = "another-session"
|
|
_, err = service.ConsumeOperationProof(proof.ProofToken, other, service.VerificationOperation{Scope: "2fa.setup"})
|
|
assert.ErrorIs(t, err, service.ErrAuthTokenInvalid)
|
|
_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"})
|
|
require.NoError(t, err)
|
|
response = securityEnrollmentRequest("POST", "/api/verify", `{"method":"password","scope":"channel.key.read","password":"enrollment-password"}`, "", identity, UniversalVerify)
|
|
assert.NotContains(t, response.Body.String(), "proof_token")
|
|
common.PasswordLoginEncryptionEnabled = true
|
|
response = securityEnrollmentRequest("POST", "/api/verify", `{"method":"password","scope":"2fa.setup","password":"enrollment-password"}`, "", identity, UniversalVerify)
|
|
assert.NotContains(t, response.Body.String(), "proof_token", "encryption-required mode must reject plaintext")
|
|
common.PasswordLoginEncryptionEnabled = false
|
|
require.NoError(t, model.DB.Model(user).Update("auth_version", 2).Error)
|
|
_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"})
|
|
assert.ErrorIs(t, err, service.ErrLoginSessionRevoked)
|
|
}
|
|
|
|
func TestSecurityEnrollmentProofStartsOnlyOneSetup(t *testing.T) {
|
|
_, identity := setupSecurityEnrollmentTest(t)
|
|
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "2fa.setup"}, "password")
|
|
first := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof, identity, Setup2FA)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(first.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
second := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof, identity, Setup2FA)
|
|
assert.Equal(t, http.StatusForbidden, second.Code)
|
|
require.NoError(t, common.Unmarshal(second.Body.Bytes(), &body))
|
|
assert.False(t, body.Success)
|
|
assert.Equal(t, "SECURITY_PROOF_CONSUMED", body.Code)
|
|
}
|
|
|
|
func TestSecurityEnrollmentOperationContext(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name, scope, context string
|
|
err error
|
|
}{
|
|
{"channel", "channel.key.read", `{"channel_id":123}`, nil},
|
|
{"missing channel", "channel.key.read", ``, service.ErrVerificationContextInvalid},
|
|
{"null channel", "channel.key.read", `{"channel_id":null}`, service.ErrVerificationContextInvalid},
|
|
{"string channel", "channel.key.read", `{"channel_id":"123"}`, service.ErrVerificationContextInvalid},
|
|
{"fractional channel", "channel.key.read", `{"channel_id":123.5}`, service.ErrVerificationContextInvalid},
|
|
{"zero channel", "channel.key.read", `{"channel_id":0}`, service.ErrVerificationContextInvalid},
|
|
{"negative channel", "channel.key.read", `{"channel_id":-1}`, service.ErrVerificationContextInvalid},
|
|
{"overflow channel", "channel.key.read", `{"channel_id":18446744073709551615}`, service.ErrVerificationContextInvalid},
|
|
{"extra field", "channel.key.read", `{"channel_id":123,"extra":true}`, service.ErrVerificationContextInvalid},
|
|
{"null context", "passkey.register", `null`, service.ErrVerificationContextInvalid},
|
|
{"array context", "passkey.register", `[]`, service.ErrVerificationContextInvalid},
|
|
{"empty enrollment", "passkey.register", `{}`, nil},
|
|
{"implicit enrollment", "passkey.register", ``, nil},
|
|
{"generate access token", "access_token.generate", `{"scopes":["profile:read"],"expires_at":0}`, nil},
|
|
{"generate access token without grant", "access_token.generate", `{}`, service.ErrVerificationContextInvalid},
|
|
{"generate access token empty grant", "access_token.generate", `{"scopes":[],"expires_at":0}`, service.ErrVerificationContextInvalid},
|
|
{"generate access token negative expiry", "access_token.generate", `{"scopes":["profile:read"],"expires_at":-1}`, service.ErrVerificationContextInvalid},
|
|
{"update access token", "access_token.update", `{"token_id":3,"scopes":["profile:read"]}`, nil},
|
|
{"update access token without target", "access_token.update", `{"scopes":["profile:read"]}`, service.ErrVerificationContextInvalid},
|
|
{"update access token without grant", "access_token.update", `{"token_id":3}`, service.ErrVerificationContextInvalid},
|
|
{"update access token empty grant", "access_token.update", `{"token_id":3,"scopes":[]}`, service.ErrVerificationContextInvalid},
|
|
{"update access token target injection", "access_token.update", `{"token_id":3,"scopes":["profile:read"],"user_id":42}`, service.ErrVerificationContextInvalid},
|
|
{"revoke access token", "access_token.revoke", `{"token_id":3}`, nil},
|
|
{"revoke legacy access token", "access_token.revoke", `{"legacy":true}`, nil},
|
|
{"revoke access token without target", "access_token.revoke", ``, service.ErrVerificationContextInvalid},
|
|
{"revoke access token two targets", "access_token.revoke", `{"token_id":3,"legacy":true}`, service.ErrVerificationContextInvalid},
|
|
{"access token target injection", "access_token.revoke", `{"user_id":42}`, service.ErrVerificationContextInvalid},
|
|
{"enrollment target injection", "passkey.register", `{"user_id":42}`, service.ErrVerificationContextInvalid},
|
|
{"admin user", "admin.user.delete", `{"user_id":7}`, nil},
|
|
{"admin user zero", "admin.user.delete", `{"user_id":0}`, service.ErrVerificationContextInvalid},
|
|
{"admin user extra field", "admin.user.update", `{"user_id":7,"password":"x"}`, service.ErrVerificationContextInvalid},
|
|
{"admin manage", "admin.user.manage", `{"user_id":7,"action":"promote"}`, nil},
|
|
{"admin manage quota", "admin.user.manage", `{"user_id":7,"action":"add_quota"}`, service.ErrVerificationContextInvalid},
|
|
{"admin manage delete", "admin.user.manage", `{"user_id":7,"action":"delete"}`, service.ErrVerificationContextInvalid},
|
|
{"admin binding type", "admin.user.binding.clear", `{"user_id":7,"binding_type":"github"}`, nil},
|
|
{"admin binding provider", "admin.user.binding.clear", `{"user_id":7,"provider_id":3}`, nil},
|
|
{"admin binding both", "admin.user.binding.clear", `{"user_id":7,"binding_type":"github","provider_id":3}`, service.ErrVerificationContextInvalid},
|
|
{"admin binding neither", "admin.user.binding.clear", `{"user_id":7}`, service.ErrVerificationContextInvalid},
|
|
{"admin create administrator", "admin.user.create", `{"role":10}`, nil},
|
|
{"admin create common user", "admin.user.create", `{"role":1}`, service.ErrVerificationContextInvalid},
|
|
{"admin create unknown role", "admin.user.create", `{"role":50}`, service.ErrVerificationContextInvalid},
|
|
{"unknown scope", "user.email.change", `{}`, service.ErrProofScope},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
_, err := service.BindVerificationOperation(service.VerificationOperation{Scope: test.scope, Context: []byte(test.context)})
|
|
assert.ErrorIs(t, err, test.err)
|
|
})
|
|
}
|
|
var first, reordered service.VerificationOperation
|
|
require.NoError(t, common.UnmarshalJsonStr(`{"scope":"channel.key.read","context":{"channel_id":123}}`, &first))
|
|
require.NoError(t, common.UnmarshalJsonStr(`{"context": { "channel_id": 123 }, "scope":"channel.key.read"}`, &reordered))
|
|
firstBinding, err := service.BindVerificationOperation(first)
|
|
require.NoError(t, err)
|
|
secondBinding, err := service.BindVerificationOperation(reordered)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, firstBinding, secondBinding)
|
|
sortedGrant, err := service.BindVerificationOperation(service.VerificationOperation{Scope: "access_token.generate", Context: []byte(`{"scopes":["profile:read","usage:read"],"expires_at":0}`)})
|
|
require.NoError(t, err)
|
|
unsortedGrant, err := service.BindVerificationOperation(service.VerificationOperation{Scope: "access_token.generate", Context: []byte(`{"expires_at":0,"scopes":["usage:read"," profile:read","usage:read"]}`)})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, sortedGrant, unsortedGrant, "a grant binds as a set")
|
|
}
|
|
|
|
func TestSecurityEnrollmentChannelProofRejectsMismatchesBeforeConsumption(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
require.NoError(t, model.DB.Model(user).Update("role", common.RoleRootUser).Error)
|
|
require.NoError(t, model.PublishUserAuthCache(user.Id))
|
|
twoFA := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
|
|
require.NoError(t, model.DB.Create(twoFA).Error)
|
|
operation := service.VerificationOperation{Scope: service.VerificationScopeChannelKeyRead, Context: []byte(`{"channel_id":123}`)}
|
|
code, err := totp.GenerateCode(twoFA.Secret, time.Now())
|
|
require.NoError(t, err)
|
|
proof, err := service.VerifySecurityInput(identity, service.VerificationInput{Method: "2fa", Scope: operation.Scope, Context: operation.Context, Code: code})
|
|
require.NoError(t, err)
|
|
for _, test := range []struct {
|
|
name string
|
|
identity service.AuthIdentity
|
|
operation service.VerificationOperation
|
|
err error
|
|
}{
|
|
{"channel", identity, service.VerificationOperation{Scope: operation.Scope, Context: []byte(`{"channel_id":456}`)}, service.ErrProofContext},
|
|
{"scope", identity, service.VerificationOperation{Scope: "passkey.delete"}, service.ErrProofScope},
|
|
{"user", service.AuthIdentity{UserID: user.Id + 1, SessionID: identity.SessionID, UserAuthVersion: identity.UserAuthVersion, SessionVersion: identity.SessionVersion}, operation, service.ErrAuthTokenInvalid},
|
|
{"session", service.AuthIdentity{UserID: user.Id, SessionID: "other-session", UserAuthVersion: identity.UserAuthVersion, SessionVersion: identity.SessionVersion}, operation, service.ErrAuthTokenInvalid},
|
|
{"session version", service.AuthIdentity{UserID: user.Id, SessionID: identity.SessionID, UserAuthVersion: identity.UserAuthVersion, SessionVersion: identity.SessionVersion + 1}, operation, service.ErrAuthTokenInvalid},
|
|
{"user version", service.AuthIdentity{UserID: user.Id, SessionID: identity.SessionID, UserAuthVersion: identity.UserAuthVersion + 1, SessionVersion: identity.SessionVersion}, operation, service.ErrAuthTokenInvalid},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
_, err := service.ConsumeOperationProof(proof.ProofToken, test.identity, test.operation)
|
|
assert.ErrorIs(t, err, test.err)
|
|
})
|
|
}
|
|
require.NoError(t, model.DB.Model(twoFA).Update("is_enabled", false).Error)
|
|
_, err = service.ConsumeOperationProof(proof.ProofToken, identity, operation)
|
|
assert.ErrorIs(t, err, service.ErrProofMethod)
|
|
require.NoError(t, model.DB.Model(twoFA).Update("is_enabled", true).Error)
|
|
authorization, err := service.ConsumeOperationProof(proof.ProofToken, identity, operation)
|
|
require.NoError(t, err)
|
|
assert.Positive(t, authorization.ProofID)
|
|
_, err = service.ConsumeOperationProof(proof.ProofToken, identity, operation)
|
|
assert.ErrorIs(t, err, service.ErrProofConsumed)
|
|
}
|
|
|
|
func TestSecurityEnrollmentProofConcurrentConsumption(t *testing.T) {
|
|
_, identity := setupSecurityEnrollmentTest(t)
|
|
operation := service.VerificationOperation{Scope: "passkey.register"}
|
|
proof := issueSecurityEnrollmentProof(t, identity, operation, "password")
|
|
start := make(chan struct{})
|
|
results := make(chan error, 2)
|
|
for range 2 {
|
|
go func() {
|
|
<-start
|
|
_, err := service.ConsumeOperationProof(proof, identity, operation)
|
|
results <- err
|
|
}()
|
|
}
|
|
close(start)
|
|
successes := 0
|
|
for range 2 {
|
|
if err := <-results; err != nil {
|
|
assert.ErrorIs(t, err, service.ErrProofConsumed)
|
|
} else {
|
|
successes++
|
|
}
|
|
}
|
|
assert.Equal(t, 1, successes)
|
|
}
|
|
|
|
func TestSecurityEnrollmentProofRequiresLiveRecordAndExactDeadline(t *testing.T) {
|
|
_, identity := setupSecurityEnrollmentTest(t)
|
|
operation := service.VerificationOperation{Scope: "passkey.register"}
|
|
proof := issueSecurityEnrollmentProof(t, identity, operation, "password")
|
|
claims := jwt.MapClaims{}
|
|
_, _, err := jwt.NewParser().ParseUnverified(proof, claims)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, float64(60), claims["exp"].(float64)-claims["iat"].(float64))
|
|
assert.NotEmpty(t, claims["context_hash"])
|
|
assert.NotContains(t, claims, "context")
|
|
assert.NotContains(t, claims, "channel_id")
|
|
var stored model.AuthFlow
|
|
require.NoError(t, model.DB.Where("purpose = ?", model.AuthFlowPurposeSecurityProof).First(&stored).Error)
|
|
assert.Equal(t, int64(claims["exp"].(float64)), stored.ExpiresAt.Unix())
|
|
assert.NotEqual(t, proof, stored.TokenHash)
|
|
assert.NotEqual(t, claims["jti"], stored.TokenHash)
|
|
require.NoError(t, model.DB.Model(&stored).Update("expires_at", time.Now()).Error)
|
|
_, err = service.ConsumeOperationProof(proof, identity, operation)
|
|
assert.ErrorIs(t, err, service.ErrAuthTokenExpired, "database deadline must reject even while the JWT is within its clock tolerance")
|
|
require.NoError(t, model.DB.Delete(&stored).Error)
|
|
_, err = service.ConsumeOperationProof(proof, identity, operation)
|
|
assert.ErrorIs(t, err, service.ErrAuthTokenInvalid)
|
|
}
|
|
|
|
func TestSecurityEnrollmentProofIsBurnedAfterBusinessFailure(t *testing.T) {
|
|
_, identity := setupSecurityEnrollmentTest(t)
|
|
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password")
|
|
require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("security_flow_creation_failure", func(tx *gorm.DB) {
|
|
if tx.Statement.Table == "auth_flows" {
|
|
tx.AddError(errors.New("injected creation failure"))
|
|
}
|
|
}))
|
|
response := securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin)
|
|
assert.Equal(t, http.StatusInternalServerError, response.Code)
|
|
require.NoError(t, model.DB.Callback().Create().Remove("security_flow_creation_failure"))
|
|
response = securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin)
|
|
assert.Equal(t, http.StatusForbidden, response.Code)
|
|
assert.Contains(t, response.Body.String(), "SECURITY_PROOF_CONSUMED")
|
|
}
|
|
|
|
func TestSecurityEnrollmentProofStorageErrorsFailClosed(t *testing.T) {
|
|
for _, stage := range []string{"issuance", "consumption"} {
|
|
t.Run(stage, func(t *testing.T) {
|
|
_, identity := setupSecurityEnrollmentTest(t)
|
|
proof := ""
|
|
if stage == "consumption" {
|
|
proof = issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password")
|
|
}
|
|
failure := func(tx *gorm.DB) {
|
|
if tx.Statement.Table == "auth_flows" {
|
|
tx.AddError(errors.New("private proof database failure"))
|
|
}
|
|
}
|
|
if stage == "issuance" {
|
|
require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("proof_storage_failure", failure))
|
|
} else {
|
|
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("proof_storage_failure", failure))
|
|
}
|
|
var response *httptest.ResponseRecorder
|
|
if stage == "issuance" {
|
|
response = securityEnrollmentRequest("POST", "/api/verify", `{"method":"password","scope":"passkey.register","password":"enrollment-password"}`, "", identity, UniversalVerify)
|
|
require.NoError(t, model.DB.Callback().Create().Remove("proof_storage_failure"))
|
|
} else {
|
|
response = securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin)
|
|
require.NoError(t, model.DB.Callback().Update().Remove("proof_storage_failure"))
|
|
}
|
|
assert.Equal(t, http.StatusInternalServerError, response.Code)
|
|
assert.Contains(t, response.Body.String(), "AUTH_INTERNAL_ERROR")
|
|
assert.NotContains(t, response.Body.String(), "private")
|
|
assert.NotContains(t, response.Body.String(), "proof_token")
|
|
assert.NotContains(t, response.Body.String(), "flow_token")
|
|
var count int64
|
|
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposePasskeyRegister).Count(&count).Error)
|
|
assert.Zero(t, count)
|
|
if stage == "consumption" {
|
|
response = securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin)
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
assert.True(t, result.Success, "a failed consumption transaction must not burn the proof")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityEnrollmentVerificationTransportsRejectInvalidContext(t *testing.T) {
|
|
_, identity := setupSecurityEnrollmentTest(t)
|
|
oauth.Register("enrollment-oauth", &enrollmentOAuthProvider{externalID: "linked-user"})
|
|
t.Cleanup(func() { oauth.Unregister("enrollment-oauth") })
|
|
for _, test := range []struct {
|
|
path, body string
|
|
handler gin.HandlerFunc
|
|
}{
|
|
{"/api/verify", `{"method":"password","scope":"passkey.register","context":{"user_id":999},"password":"enrollment-password"}`, UniversalVerify},
|
|
{"/api/user/passkey/verify/begin", `{"scope":"passkey.register","context":{"user_id":999}}`, PasskeyVerifyBegin},
|
|
{"/api/oauth/state", `{"provider":"enrollment-oauth","intent":"verify","scope":"passkey.register","context":{"user_id":999}}`, GenerateOAuthCode},
|
|
} {
|
|
t.Run(test.path, func(t *testing.T) {
|
|
response := securityEnrollmentRequest("POST", test.path, test.body, "", identity, test.handler)
|
|
assert.Equal(t, http.StatusBadRequest, response.Code)
|
|
assert.Contains(t, response.Body.String(), "SECURITY_CONTEXT_INVALID")
|
|
})
|
|
}
|
|
var count int64
|
|
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Count(&count).Error)
|
|
assert.Zero(t, count)
|
|
}
|
|
|
|
func TestSecurityEnrollmentPendingPasskeyRejectsChangedAuthorization(t *testing.T) {
|
|
for _, change := range []string{"expired", "revoked", "session version", "user version", "method", "other session"} {
|
|
t.Run(change, func(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password")
|
|
response := securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin)
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
require.True(t, result.Success, result.Message)
|
|
var begin struct {
|
|
FlowToken string `json:"flow_token"`
|
|
Options struct {
|
|
PublicKey struct {
|
|
Challenge string `json:"challenge"`
|
|
} `json:"publicKey"`
|
|
} `json:"options"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(result.Data, &begin))
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
body, err := common.Marshal(passkeyFinishRequest{
|
|
FlowToken: begin.FlowToken, Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, true, 0),
|
|
})
|
|
require.NoError(t, err)
|
|
switch change {
|
|
case "expired":
|
|
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposePasskeyRegister).Update("expires_at", time.Now().Add(-time.Minute)).Error)
|
|
case "revoked":
|
|
_, err = model.RevokeUserSession(user.Id, identity.SessionID, "security-test")
|
|
require.NoError(t, err)
|
|
case "session version":
|
|
require.NoError(t, model.DB.Model(&model.UserSession{}).Where("sid = ?", identity.SessionID).Update("version", 2).Error)
|
|
case "user version":
|
|
require.NoError(t, model.DB.Model(user).Update("auth_version", 2).Error)
|
|
case "method":
|
|
require.NoError(t, model.DB.Model(user).Update("password", "").Error)
|
|
case "other session":
|
|
other, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "other-session")
|
|
require.NoError(t, err)
|
|
identity, err = service.ParseAccessToken(other.AccessToken)
|
|
require.NoError(t, err)
|
|
}
|
|
response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(body), "", identity, PasskeyRegisterFinish)
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
assert.False(t, result.Success)
|
|
_, err = model.GetPasskeyByUserID(user.Id)
|
|
assert.ErrorIs(t, err, model.ErrPasskeyNotFound)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityEnrollmentPasskeyProofProtectsChannelKeyRead(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
require.NoError(t, model.DB.Model(user).Update("role", common.RoleRootUser).Error)
|
|
require.NoError(t, model.PublishUserAuthCache(user.Id))
|
|
require.NoError(t, model.DB.AutoMigrate(&model.Channel{}))
|
|
for _, channel := range []model.Channel{
|
|
{Id: 123, Name: "first", Key: "first-channel-secret", Type: 1, Status: 1},
|
|
{Id: 456, Name: "second", Key: "second-channel-secret", Type: 1, Status: 1},
|
|
} {
|
|
require.NoError(t, model.DB.Create(&channel).Error)
|
|
}
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
registrationProof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password")
|
|
response := securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", registrationProof, identity, PasskeyRegisterBegin)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
var begin struct {
|
|
FlowToken string `json:"flow_token"`
|
|
Options struct {
|
|
PublicKey struct {
|
|
Challenge string `json:"challenge"`
|
|
} `json:"publicKey"`
|
|
} `json:"options"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(body.Data, &begin))
|
|
require.NotEmpty(t, begin.Options.PublicKey.Challenge)
|
|
registrationBody, err := common.Marshal(passkeyFinishRequest{
|
|
FlowToken: begin.FlowToken, Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, true, 0),
|
|
})
|
|
require.NoError(t, err)
|
|
// The dedicated registration flow remains authorized after the consumed proof expires.
|
|
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Update("expires_at", time.Now().Add(-time.Minute)).Error)
|
|
response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(registrationBody), "", identity, PasskeyRegisterFinish)
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
var rotation struct {
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(body.Data, &rotation))
|
|
identity, err = service.ParseAccessToken(rotation.AccessToken)
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 2, identity.UserAuthVersion)
|
|
response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(registrationBody), "", identity, PasskeyRegisterFinish)
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
assert.False(t, body.Success)
|
|
|
|
response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/begin", `{"scope":"channel.key.read","context":{"channel_id":123}}`, "", identity, PasskeyVerifyBegin)
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
require.NoError(t, common.Unmarshal(body.Data, &begin))
|
|
assertionBody, err := common.Marshal(map[string]any{
|
|
"flow_token": begin.FlowToken, "credential": securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, false, 1),
|
|
"scope": "passkey.delete", "context": map[string]any{"channel_id": 456},
|
|
})
|
|
require.NoError(t, err)
|
|
response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/finish", string(assertionBody), "", identity, PasskeyVerifyFinish)
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
var proof service.SecurityProof
|
|
require.NoError(t, common.Unmarshal(body.Data, &proof))
|
|
assert.Equal(t, "passkey", proof.Method)
|
|
assert.Equal(t, "channel.key.read", proof.Scope, "finish cannot replace the operation approved at begin")
|
|
|
|
router := gin.New()
|
|
router.POST("/api/channel/:id/key", middleware.RootAuth(), middleware.SecureVerificationRequired(), GetChannelKey)
|
|
for _, test := range []struct {
|
|
name, path, proof, code, key string
|
|
status int
|
|
}{
|
|
{"login token only", "/api/channel/123/key", "", "SECURITY_PROOF_REQUIRED", "", http.StatusForbidden},
|
|
{"access token as proof", "/api/channel/123/key", rotation.AccessToken, "SECURITY_PROOF_INVALID", "", http.StatusForbidden},
|
|
{"other channel", "/api/channel/456/key", proof.ProofToken, "SECURITY_PROOF_CONTEXT_MISMATCH", "", http.StatusForbidden},
|
|
{"authorized channel", "/api/channel/123/key", proof.ProofToken, "", "first-channel-secret", http.StatusOK},
|
|
{"replay", "/api/channel/123/key", proof.ProofToken, "SECURITY_PROOF_CONSUMED", "", http.StatusForbidden},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
request := httptest.NewRequest("POST", test.path, nil)
|
|
request.Header.Set("Authorization", "Bearer "+rotation.AccessToken)
|
|
request.Header.Set("X-Security-Proof", test.proof)
|
|
result := httptest.NewRecorder()
|
|
router.ServeHTTP(result, request)
|
|
assert.Equal(t, test.status, result.Code)
|
|
var response securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(result.Body.Bytes(), &response))
|
|
assert.Equal(t, test.code, response.Code)
|
|
if test.key != "" {
|
|
assert.Contains(t, string(response.Data), test.key)
|
|
} else {
|
|
assert.NotContains(t, result.Body.String(), "channel-secret")
|
|
}
|
|
})
|
|
}
|
|
var logs []model.AuditLog
|
|
require.NoError(t, model.LOG_DB.Where("action = ?", "channel.key_view").Find(&logs).Error)
|
|
require.Len(t, logs, 1)
|
|
encodedLogs, err := common.Marshal(logs)
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, string(encodedLogs), "first-channel-secret")
|
|
assert.NotContains(t, string(encodedLogs), proof.ProofToken)
|
|
|
|
response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/begin", `{"scope":"passkey.delete"}`, "", identity, PasskeyVerifyBegin)
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
require.NoError(t, common.Unmarshal(body.Data, &begin))
|
|
deleteAssertion, err := common.Marshal(passkeyFinishRequest{
|
|
FlowToken: begin.FlowToken, Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, false, 2),
|
|
})
|
|
require.NoError(t, err)
|
|
response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/finish", string(deleteAssertion), "", identity, PasskeyVerifyFinish)
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
require.NoError(t, common.Unmarshal(body.Data, &proof))
|
|
response = securityEnrollmentRequest("DELETE", "/api/user/passkey", "", proof.ProofToken, identity, PasskeyDelete)
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
_, err = model.GetPasskeyByUserID(user.Id)
|
|
assert.ErrorIs(t, err, model.ErrPasskeyNotFound)
|
|
}
|
|
|
|
func TestSecurityEnrollmentVerifyRequiresDedicatedFlowForInteractiveMethods(t *testing.T) {
|
|
for _, method := range []string{service.VerificationMethodPasskey, service.VerificationMethodOAuth} {
|
|
t.Run(method, func(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
if method == service.VerificationMethodPasskey {
|
|
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "enrolled-passkey", PublicKey: "public-key"}).Error)
|
|
} else {
|
|
require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "github_id": "linked-user"}).Error)
|
|
oauth.Register("enrollment-oauth", &enrollmentOAuthProvider{externalID: "linked-user"})
|
|
t.Cleanup(func() { oauth.Unregister("enrollment-oauth") })
|
|
}
|
|
_, err := service.RequireVerificationMethod(identity, service.VerificationScopeTwoFASetup, method)
|
|
require.NoError(t, err)
|
|
payload, err := common.Marshal(service.VerificationInput{Method: method, Scope: service.VerificationScopeTwoFASetup})
|
|
require.NoError(t, err)
|
|
response := securityEnrollmentRequest("POST", "/api/verify", string(payload), "", identity, UniversalVerify)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
assert.Equal(t, http.StatusBadRequest, response.Code)
|
|
assert.False(t, body.Success)
|
|
assert.Equal(t, "SECURITY_VERIFICATION_FLOW_REQUIRED", body.Code)
|
|
assert.Equal(t, "This verification method requires its dedicated verification flow.", body.Message)
|
|
assert.Empty(t, body.Data)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityEnrollmentTwoFAFlowAndSessionRotation(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
other, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "other-device")
|
|
require.NoError(t, err)
|
|
var proof string
|
|
var setups []service.TwoFASetup
|
|
for range 2 {
|
|
proof = issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "2fa.setup"}, "password")
|
|
response := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof, identity, Setup2FA)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
var setup service.TwoFASetup
|
|
require.NoError(t, common.Unmarshal(body.Data, &setup))
|
|
require.NotEmpty(t, setup.FlowToken)
|
|
require.Len(t, setup.BackupCodes, common.BackupCodeCount)
|
|
setups = append(setups, setup)
|
|
}
|
|
oldCode, err := totp.GenerateCode(setups[0].Secret, time.Now())
|
|
require.NoError(t, err)
|
|
assert.ErrorIs(t, service.FinishTwoFASetup(identity, setups[0].FlowToken, oldCode), model.ErrTwoFASetupInvalid)
|
|
assert.ErrorIs(t, service.FinishTwoFASetup(identity, setups[1].FlowToken, "not-a-code"), model.ErrTwoFACodeInvalid)
|
|
_, err = model.GetAuthFlow(setups[1].FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFASetup})
|
|
require.NoError(t, err, "invalid code must not consume setup")
|
|
code, err := totp.GenerateCode(setups[1].Secret, time.Now())
|
|
require.NoError(t, err)
|
|
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Update("expires_at", time.Now().Add(-time.Minute)).Error)
|
|
payload, err := common.Marshal(Verify2FARequest{Code: code, FlowToken: setups[1].FlowToken})
|
|
require.NoError(t, err)
|
|
response := securityEnrollmentRequest("POST", "/api/user/2fa/enable", string(payload), "", identity, Enable2FA)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
var rotated struct {
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(body.Data, &rotated))
|
|
newIdentity, err := service.ParseAccessToken(rotated.AccessToken)
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 2, newIdentity.UserAuthVersion)
|
|
assert.Equal(t, identity.SessionID, newIdentity.SessionID)
|
|
otherSession, err := model.GetUserSessionBySID(other.Session.SID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, model.UserSessionStatusRevoked, otherSession.Status)
|
|
_, err = model.GetAuthFlow(setups[1].FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFASetup})
|
|
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
|
|
_, err = service.ConsumeOperationProof(proof, newIdentity, service.VerificationOperation{Scope: "2fa.setup"})
|
|
assert.Error(t, err)
|
|
enabled, err := model.GetTwoFAByUserId(user.Id)
|
|
require.NoError(t, err)
|
|
assert.True(t, enabled.IsEnabled)
|
|
var logs []model.AuditLog
|
|
require.NoError(t, model.LOG_DB.Find(&logs).Error)
|
|
require.NotEmpty(t, logs)
|
|
encoded, err := common.Marshal(logs)
|
|
require.NoError(t, err)
|
|
for _, secret := range []string{setups[0].Secret, setups[1].Secret, proof, code, setups[1].FlowToken} {
|
|
assert.NotContains(t, string(encoded), secret)
|
|
}
|
|
}
|
|
|
|
func TestSecurityEnrollmentSetupAndEnableRollback(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
setup, err := service.StartTwoFASetup(identity, authorizeSecurityEnrollment(t, identity))
|
|
require.NoError(t, err)
|
|
failure := errors.New("injected storage failure")
|
|
authorization := authorizeSecurityEnrollment(t, identity)
|
|
require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("security_setup_failure", func(tx *gorm.DB) {
|
|
if tx.Statement.Table == "auth_flows" {
|
|
tx.AddError(failure)
|
|
}
|
|
}))
|
|
_, err = service.StartTwoFASetup(identity, authorization)
|
|
assert.ErrorIs(t, err, failure)
|
|
require.NoError(t, model.DB.Callback().Create().Remove("security_setup_failure"))
|
|
pending, err := model.GetTwoFAByUserId(user.Id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, setup.Secret, pending.Secret)
|
|
count, err := model.GetUnusedBackupCodeCount(user.Id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, common.BackupCodeCount, count)
|
|
code, err := totp.GenerateCode(setup.Secret, time.Now())
|
|
require.NoError(t, err)
|
|
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("security_enable_failure", func(tx *gorm.DB) {
|
|
if tx.Statement.Table == "two_fas" {
|
|
tx.AddError(failure)
|
|
}
|
|
}))
|
|
assert.ErrorIs(t, service.FinishTwoFASetup(identity, setup.FlowToken, code), failure)
|
|
require.NoError(t, model.DB.Callback().Update().Remove("security_enable_failure"))
|
|
_, err = model.GetAuthFlow(setup.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFASetup})
|
|
require.NoError(t, err)
|
|
storedUser, err := model.GetUserById(user.Id, false)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, identity.UserAuthVersion, storedUser.AuthVersion)
|
|
require.NoError(t, service.FinishTwoFASetup(identity, setup.FlowToken, code))
|
|
}
|
|
|
|
type enrollmentOAuthProvider struct {
|
|
authFlowTestOAuthProvider
|
|
externalID string
|
|
disabled bool
|
|
}
|
|
|
|
func (*enrollmentOAuthProvider) ProviderUserIDColumn() string { return "github_id" }
|
|
func (p *enrollmentOAuthProvider) IsEnabled() bool { return !p.disabled }
|
|
func (p *enrollmentOAuthProvider) GetUserInfo(context.Context, *oauth.OAuthToken) (*oauth.OAuthUser, error) {
|
|
return &oauth.OAuthUser{ProviderUserID: p.externalID, Email: "same@example.com"}, nil
|
|
}
|
|
|
|
func TestSecurityEnrollmentOAuthVerificationNeverChangesLoginOrBindings(t *testing.T) {
|
|
for _, scenario := range []string{"success", "different account", "binding changed", "session changed", "auth version changed", "provider disabled", "cancelled"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "github_id": "linked-user", "email": "same@example.com"}).Error)
|
|
provider := &enrollmentOAuthProvider{externalID: "linked-user"}
|
|
oauth.Register("enrollment-oauth", provider)
|
|
t.Cleanup(func() { oauth.Unregister("enrollment-oauth") })
|
|
response := securityEnrollmentRequest("POST", "/api/oauth/state", `{"provider":"enrollment-oauth","intent":"verify","scope":"2fa.setup"}`, "", identity, GenerateOAuthCode)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
var started struct {
|
|
FlowToken string `json:"flow_token"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(body.Data, &started))
|
|
callbackIdentity := identity
|
|
query := "&code=authorization-code"
|
|
switch scenario {
|
|
case "different account":
|
|
provider.externalID = "other-user"
|
|
case "binding changed":
|
|
require.NoError(t, model.DB.Model(user).Update("github_id", "replacement-user").Error)
|
|
case "session changed":
|
|
callbackIdentity.SessionID = "different-session"
|
|
case "auth version changed":
|
|
require.NoError(t, model.DB.Model(user).Update("auth_version", 2).Error)
|
|
case "cancelled":
|
|
query = "&error=access_denied"
|
|
case "provider disabled":
|
|
provider.disabled = true
|
|
}
|
|
handler := func(c *gin.Context) {
|
|
c.Params = gin.Params{{Key: "provider", Value: "enrollment-oauth"}}
|
|
HandleOAuth(c)
|
|
}
|
|
path := "/api/oauth/enrollment-oauth?state=" + started.FlowToken + query
|
|
response = securityEnrollmentRequest("GET", path, "", "", callbackIdentity, handler)
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
assert.Equal(t, scenario == "success", body.Success, body.Message)
|
|
assert.NotContains(t, response.Body.String(), "access_token")
|
|
assert.Empty(t, response.Header().Values("Set-Cookie"))
|
|
if scenario == "success" {
|
|
var proof service.SecurityProof
|
|
require.NoError(t, common.Unmarshal(body.Data, &proof))
|
|
_, err := service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"})
|
|
require.NoError(t, err)
|
|
replayed := securityEnrollmentRequest("GET", path, "", "", identity, handler)
|
|
assert.Equal(t, http.StatusForbidden, replayed.Code)
|
|
}
|
|
var users, sessions, bindings int64
|
|
require.NoError(t, model.DB.Model(&model.User{}).Count(&users).Error)
|
|
require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&sessions).Error)
|
|
require.NoError(t, model.DB.Model(&model.UserOAuthBinding{}).Count(&bindings).Error)
|
|
assert.EqualValues(t, 1, users)
|
|
assert.EqualValues(t, 1, sessions)
|
|
assert.Zero(t, bindings)
|
|
stored, err := model.GetUserById(user.Id, false)
|
|
require.NoError(t, err)
|
|
expectedBinding := "linked-user"
|
|
if scenario == "binding changed" {
|
|
expectedBinding = "replacement-user"
|
|
}
|
|
assert.Equal(t, expectedBinding, stored.GitHubId)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityEnrollmentEncryptedPasswordVerification(t *testing.T) {
|
|
_, identity := setupSecurityEnrollmentTest(t)
|
|
keyID, publicPEM := common.PasswordEncryptionPublicKey()
|
|
if keyID == "" {
|
|
privatePEM, err := common.GeneratePasswordEncryptionPrivateKey()
|
|
require.NoError(t, err)
|
|
require.NoError(t, common.LoadPasswordEncryptionPrivateKey(privatePEM))
|
|
keyID, publicPEM = common.PasswordEncryptionPublicKey()
|
|
}
|
|
block, _ := pem.Decode([]byte(publicPEM))
|
|
require.NotNil(t, block)
|
|
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
|
|
require.NoError(t, err)
|
|
publicKey, ok := parsed.(*rsa.PublicKey)
|
|
require.True(t, ok)
|
|
ciphertext, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, publicKey, []byte("enrollment-password"), nil)
|
|
require.NoError(t, err)
|
|
common.PasswordLoginEncryptionEnabled = true
|
|
input := service.VerificationInput{Method: "password", Scope: "passkey.register", PasswordEncrypted: base64.StdEncoding.EncodeToString(ciphertext), EncryptionKeyID: keyID}
|
|
proof, err := service.VerifySecurityInput(identity, input)
|
|
require.NoError(t, err)
|
|
_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: input.Scope})
|
|
require.NoError(t, err)
|
|
input.EncryptionKeyID = "incorrect-key-id"
|
|
_, err = service.VerifySecurityInput(identity, input)
|
|
assert.ErrorIs(t, err, service.ErrVerificationFailed)
|
|
}
|
|
|
|
func TestSecurityEnrollmentTwoFAFailureAccountingAndStorageErrors(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
twoFA := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
|
|
require.NoError(t, model.DB.Create(twoFA).Error)
|
|
for _, endpoint := range []struct {
|
|
path string
|
|
handler gin.HandlerFunc
|
|
}{
|
|
{"/api/user/2fa/disable", Disable2FA},
|
|
{"/api/user/2fa/backup_codes", RegenerateBackupCodes},
|
|
{"/api/user/login/2fa", Verify2FALogin},
|
|
} {
|
|
response := securityEnrollmentRequest("POST", endpoint.path, `{}`, "", identity, endpoint.handler)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
assert.False(t, body.Success, endpoint.path)
|
|
if endpoint.path == "/api/user/login/2fa" {
|
|
assert.Equal(t, "Invalid parameters", body.Message, endpoint.path)
|
|
} else {
|
|
assert.Equal(t, "SECURITY_PROOF_REQUIRED", body.Code, endpoint.path)
|
|
}
|
|
}
|
|
stored, err := model.GetTwoFAByUserId(user.Id)
|
|
require.NoError(t, err)
|
|
assert.Zero(t, stored.FailedAttempts, "missing required input must not count as a failed verification")
|
|
wrongCode := ""
|
|
for _, candidate := range []string{"000000", "111111", "222222", "333333"} {
|
|
if !common.ValidateTOTPCode(twoFA.Secret, candidate) {
|
|
wrongCode = candidate
|
|
break
|
|
}
|
|
}
|
|
require.NotEmpty(t, wrongCode)
|
|
assert.ErrorIs(t, service.VerifyTwoFactorCode(twoFA, wrongCode), service.ErrVerificationFailed)
|
|
stored, err = model.GetTwoFAByUserId(user.Id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, stored.FailedAttempts)
|
|
hash, err := common.HashBackupCode("ABCD-1234")
|
|
require.NoError(t, err)
|
|
require.NoError(t, model.DB.Create(&model.TwoFABackupCode{UserId: user.Id, CodeHash: hash}).Error)
|
|
require.NoError(t, service.VerifyTwoFactorCode(stored, "ABCD-1234"))
|
|
assert.ErrorIs(t, service.VerifyTwoFactorCode(stored, "ABCD-1234"), service.ErrVerificationFailed)
|
|
until := time.Now().Add(time.Minute)
|
|
stored.LockedUntil = &until
|
|
assert.ErrorIs(t, service.VerifyTwoFactorCode(stored, "ABCD-1234"), service.ErrVerificationLocked)
|
|
stored.LockedUntil = nil
|
|
code, err := totp.GenerateCode(stored.Secret, time.Now())
|
|
require.NoError(t, err)
|
|
failure := errors.New("usage storage failed")
|
|
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("security_usage_failure", func(tx *gorm.DB) {
|
|
if tx.Statement.Table == "two_fas" {
|
|
tx.AddError(failure)
|
|
}
|
|
}))
|
|
assert.ErrorIs(t, service.VerifyTwoFactorCode(stored, code), failure)
|
|
require.NoError(t, model.DB.Callback().Update().Remove("security_usage_failure"))
|
|
}
|
|
|
|
func TestSecurityEnrollmentDatabaseErrorsAreNotReturned(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name, method, path, body, table string
|
|
create bool
|
|
handler gin.HandlerFunc
|
|
}{
|
|
{"methods", "GET", "/api/verify/methods?scope=2fa.setup", "", "users", false, GetVerificationMethods},
|
|
{"password", "POST", "/api/verify", `{"method":"password","scope":"2fa.setup","password":"enrollment-password"}`, "users", false, UniversalVerify},
|
|
{"passkey", "POST", "/api/user/passkey/register/begin", "", "users", false, PasskeyRegisterBegin},
|
|
{"2fa status", "GET", "/api/user/2fa/status", "", "two_fas", false, Get2FAStatus},
|
|
{"2fa setup", "POST", "/api/user/2fa/setup", "", "two_fas", true, Setup2FA},
|
|
{"2fa flow", "POST", "/api/user/2fa/setup", "", "auth_flows", true, Setup2FA},
|
|
{"oauth state", "POST", "/api/oauth/state", `{"provider":"enrollment-oauth","intent":"verify","scope":"2fa.setup"}`, "users", false, GenerateOAuthCode},
|
|
{"channel key", "POST", "/api/channel/1/key", "", "channels", false, func(c *gin.Context) {
|
|
c.Params = gin.Params{{Key: "id", Value: "1"}}
|
|
GetChannelKey(c)
|
|
}},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
_, identity := setupSecurityEnrollmentTest(t)
|
|
oauth.Register("enrollment-oauth", &enrollmentOAuthProvider{externalID: "linked-user"})
|
|
t.Cleanup(func() { oauth.Unregister("enrollment-oauth") })
|
|
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeTwoFASetup}, service.VerificationMethodPassword)
|
|
privateError := errors.New("database connection failed: private-db-host private_table SELECT secret_column")
|
|
callback := func(tx *gorm.DB) {
|
|
if tx.Statement.Table == test.table {
|
|
tx.AddError(privateError)
|
|
}
|
|
}
|
|
if test.create {
|
|
require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("security_private_error", callback))
|
|
} else {
|
|
require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register("security_private_error", callback))
|
|
}
|
|
response := securityEnrollmentRequest(test.method, test.path, test.body, proof, identity, test.handler)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
assert.Equal(t, http.StatusInternalServerError, response.Code)
|
|
assert.False(t, body.Success)
|
|
assert.Equal(t, "AUTH_INTERNAL_ERROR", body.Code)
|
|
assert.Equal(t, "Internal Server Error", body.Message)
|
|
assert.NotContains(t, response.Body.String(), "private")
|
|
assert.NotContains(t, response.Body.String(), "SELECT")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityEnrollmentPublicErrorsDiscardWrappedDetails(t *testing.T) {
|
|
for _, test := range []struct {
|
|
err error
|
|
code string
|
|
message string
|
|
}{
|
|
{service.ErrVerificationFailed, "SECURITY_VERIFICATION_FAILED", service.ErrVerificationFailed.Error()},
|
|
{service.ErrVerificationLocked, "SECURITY_VERIFICATION_LOCKED", service.ErrVerificationLocked.Error()},
|
|
{service.ErrOAuthAccountMismatch, "OAUTH_ACCOUNT_MISMATCH", service.ErrOAuthAccountMismatch.Error()},
|
|
{model.ErrTwoFASetupInvalid, "TWOFA_SETUP_INVALID", model.ErrTwoFASetupInvalid.Error()},
|
|
} {
|
|
t.Run(test.code, func(t *testing.T) {
|
|
response := securityEnrollmentRequest("POST", "/api/verify", "", "", service.AuthIdentity{}, func(c *gin.Context) {
|
|
writeSecurityOperationError(c, fmt.Errorf("private database details: %w", test.err))
|
|
})
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
assert.False(t, body.Success)
|
|
assert.Equal(t, test.code, body.Code)
|
|
assert.Equal(t, test.message, body.Message)
|
|
assert.NotContains(t, response.Body.String(), "private")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityEnrollmentExpiredAndCrossSessionSetupsCannotActivate(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
setup, err := service.StartTwoFASetup(identity, authorizeSecurityEnrollment(t, identity))
|
|
require.NoError(t, err)
|
|
code, err := totp.GenerateCode(setup.Secret, time.Now())
|
|
require.NoError(t, err)
|
|
other, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "other-session")
|
|
require.NoError(t, err)
|
|
otherIdentity, err := service.ParseAccessToken(other.AccessToken)
|
|
require.NoError(t, err)
|
|
assert.ErrorIs(t, service.FinishTwoFASetup(otherIdentity, setup.FlowToken, code), model.ErrTwoFASetupInvalid)
|
|
flow, err := model.GetAuthFlow(setup.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFASetup})
|
|
require.NoError(t, err)
|
|
require.NoError(t, model.DB.Model(flow).Update("expires_at", time.Now().Add(-time.Minute)).Error)
|
|
assert.ErrorIs(t, service.FinishTwoFASetup(identity, setup.FlowToken, code), model.ErrTwoFASetupInvalid)
|
|
stored, err := model.GetTwoFAByUserId(user.Id)
|
|
require.NoError(t, err)
|
|
assert.False(t, stored.IsEnabled)
|
|
storedUser, err := model.GetUserById(user.Id, false)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, identity.UserAuthVersion, storedUser.AuthVersion)
|
|
}
|
|
|
|
func TestSecurityEnrollmentOAuthQueriesReachHandlerWithoutLeakingToAccessLogs(t *testing.T) {
|
|
var output bytes.Buffer
|
|
previous := gin.DefaultWriter
|
|
gin.DefaultWriter = &output
|
|
t.Cleanup(func() { gin.DefaultWriter = previous })
|
|
router := gin.New()
|
|
middleware.SetUpLogger(router)
|
|
router.GET("/api/oauth/:provider", func(c *gin.Context) {
|
|
assert.Equal(t, "private-code", c.Query("code"))
|
|
assert.Equal(t, "private-state", c.Query("state"))
|
|
c.Status(http.StatusNoContent)
|
|
})
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, httptest.NewRequest("GET", "/api/oauth/github?code=private-code&state=private-state", nil))
|
|
assert.Equal(t, http.StatusNoContent, response.Code)
|
|
assert.Contains(t, output.String(), "/api/oauth/github")
|
|
assert.NotContains(t, output.String(), "private-code")
|
|
assert.NotContains(t, output.String(), "private-state")
|
|
}
|
|
|
|
func TestSecurityEnrollmentCustomOAuthUsesExistingBinding(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
require.NoError(t, model.DB.Model(user).Update("password", "").Error)
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/token":
|
|
assert.NoError(t, r.ParseForm())
|
|
assert.Equal(t, "custom-code", r.Form.Get("code"))
|
|
_, _ = w.Write([]byte(`{"access_token":"provider-access-token","token_type":"Bearer"}`))
|
|
case "/userinfo":
|
|
assert.Equal(t, "Bearer provider-access-token", r.Header.Get("Authorization"))
|
|
_, _ = w.Write([]byte(`{"sub":"custom-user","name":"Existing user"}`))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
provider := oauth.NewGenericOAuthProvider(&model.CustomOAuthProvider{
|
|
Id: 42, Slug: "enrollment-custom", Name: "Custom provider", Enabled: true,
|
|
ClientId: "client", ClientSecret: "secret", UserIdField: "sub",
|
|
TokenEndpoint: upstream.URL + "/token", UserInfoEndpoint: upstream.URL + "/userinfo",
|
|
})
|
|
oauth.RegisterCustom("enrollment-custom", provider)
|
|
t.Cleanup(func() { oauth.Unregister("enrollment-custom") })
|
|
require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: user.Id, ProviderId: 42, ProviderUserId: "custom-user"}).Error)
|
|
response := securityEnrollmentRequest("GET", "/api/verify/methods?scope=2fa.setup", "", "", identity, GetVerificationMethods)
|
|
assert.Contains(t, response.Body.String(), "enrollment-custom")
|
|
assert.NotContains(t, response.Body.String(), "custom-user")
|
|
response = securityEnrollmentRequest("POST", "/api/oauth/state", `{"provider":"enrollment-custom","intent":"verify","scope":"2fa.setup"}`, "", identity, GenerateOAuthCode)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
var started struct {
|
|
FlowToken string `json:"flow_token"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(body.Data, &started))
|
|
response = securityEnrollmentRequest("GET", "/api/oauth/enrollment-custom?state="+started.FlowToken+"&code=custom-code", "", "", identity, func(c *gin.Context) {
|
|
c.Params = gin.Params{{Key: "provider", Value: "enrollment-custom"}}
|
|
HandleOAuth(c)
|
|
})
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
var proof service.SecurityProof
|
|
require.NoError(t, common.Unmarshal(body.Data, &proof))
|
|
_, err := service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"})
|
|
require.NoError(t, err)
|
|
bindings, err := model.GetUserOAuthBindingsByUserId(user.Id)
|
|
require.NoError(t, err)
|
|
require.Len(t, bindings, 1)
|
|
assert.Equal(t, "custom-user", bindings[0].ProviderUserId)
|
|
}
|
|
|
|
func completeFirstSecurityFactor(t *testing.T, identity service.AuthIdentity, proof service.SecurityProof) {
|
|
t.Helper()
|
|
var response *httptest.ResponseRecorder
|
|
if proof.Scope == service.VerificationScopeTwoFASetup {
|
|
response = securityEnrollmentRequest("POST", "/api/user/2fa/setup", "", proof.ProofToken, identity, Setup2FA)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
var setup service.TwoFASetup
|
|
require.NoError(t, common.Unmarshal(body.Data, &setup))
|
|
code, err := totp.GenerateCode(setup.Secret, time.Now())
|
|
require.NoError(t, err)
|
|
request, err := common.Marshal(Verify2FARequest{FlowToken: setup.FlowToken, Code: code})
|
|
require.NoError(t, err)
|
|
response = securityEnrollmentRequest("POST", "/api/user/2fa/enable", string(request), "", identity, Enable2FA)
|
|
} else {
|
|
response = securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof.ProofToken, identity, PasskeyRegisterBegin)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, body.Message)
|
|
var begin struct {
|
|
FlowToken string `json:"flow_token"`
|
|
Options struct {
|
|
PublicKey struct {
|
|
Challenge string `json:"challenge"`
|
|
} `json:"publicKey"`
|
|
} `json:"options"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(body.Data, &begin))
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
request, err := common.Marshal(passkeyFinishRequest{
|
|
FlowToken: begin.FlowToken,
|
|
Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, true, 0),
|
|
})
|
|
require.NoError(t, err)
|
|
response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(request), "", identity, PasskeyRegisterFinish)
|
|
}
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, response.Body.String())
|
|
var rotation struct {
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(body.Data, &rotation))
|
|
updated, err := service.ParseAccessToken(rotation.AccessToken)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, identity.UserID, updated.UserID)
|
|
assert.Equal(t, identity.UserAuthVersion+1, updated.UserAuthVersion)
|
|
_, err = service.ConsumeOperationProof(proof.ProofToken, updated, service.VerificationOperation{Scope: proof.Scope})
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestSecurityEnrollmentTelegramAndWeChatFirstFactor(t *testing.T) {
|
|
for _, provider := range []string{"telegram", "wechat"} {
|
|
for _, scope := range []string{service.VerificationScopeTwoFASetup, service.VerificationScopePasskeyRegister} {
|
|
t.Run(provider+"/"+scope, func(t *testing.T) {
|
|
var user *model.User
|
|
var identity service.AuthIdentity
|
|
var response *httptest.ResponseRecorder
|
|
method := service.VerificationMethodSession
|
|
if provider == "telegram" {
|
|
fixture := setupTelegramOAuthTest(t)
|
|
user, identity = fixture.user, fixture.identity
|
|
require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "telegram_id": "42"}).Error)
|
|
state, code := fixture.authorization(t, "verify", identity, scope, telegramIdentityClaims(99))
|
|
mismatch := telegramOAuthCallback(state, code, identity)
|
|
assert.Contains(t, mismatch.Body.String(), "OAUTH_ACCOUNT_MISMATCH")
|
|
assert.NotContains(t, mismatch.Body.String(), "proof_token")
|
|
state, code = fixture.authorization(t, "verify", identity, scope, telegramIdentityClaims(42))
|
|
response = telegramOAuthCallback(state, code, identity)
|
|
method = service.VerificationMethodOAuth
|
|
} else {
|
|
user, identity = setupSecurityEnrollmentTest(t)
|
|
require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "wechat_id": "wechat-user"}).Error)
|
|
request, err := common.Marshal(service.VerificationInput{Scope: scope, Method: method})
|
|
require.NoError(t, err)
|
|
response = securityEnrollmentRequest("POST", "/api/verify", string(request), "", identity, UniversalVerify)
|
|
}
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
if provider == "wechat" {
|
|
assert.False(t, body.Success)
|
|
assert.NotContains(t, response.Body.String(), "proof_token")
|
|
return
|
|
}
|
|
require.True(t, body.Success, response.Body.String())
|
|
var proof service.SecurityProof
|
|
require.NoError(t, common.Unmarshal(body.Data, &proof))
|
|
assert.Equal(t, scope, proof.Scope)
|
|
assert.Equal(t, method, proof.Method)
|
|
before, err := model.GetUserById(user.Id, true)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, before.Password)
|
|
assert.Equal(t, identity.UserAuthVersion, before.AuthVersion)
|
|
completeFirstSecurityFactor(t, identity, proof)
|
|
after, err := model.GetUserById(user.Id, true)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, before.TelegramId, after.TelegramId)
|
|
assert.Equal(t, before.WeChatId, after.WeChatId)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSecurityEnrollmentNeverTrustsSessionForFirstFactor(t *testing.T) {
|
|
for _, scenario := range []string{"wechat only", "password", "passkey", "locked 2fa", "telegram", "github", "disabled custom binding", "no binding", "binding storage failure", "revoked session"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "wechat_id": "wechat-user"}).Error)
|
|
switch scenario {
|
|
case "password":
|
|
require.NoError(t, model.DB.Model(user).Update("password", "stored-hash").Error)
|
|
case "passkey":
|
|
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "credential", PublicKey: "key"}).Error)
|
|
case "locked 2fa":
|
|
until := time.Now().Add(time.Hour)
|
|
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true, LockedUntil: &until}).Error)
|
|
case "telegram":
|
|
require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error)
|
|
case "github":
|
|
require.NoError(t, model.DB.Model(user).Update("github_id", "42").Error)
|
|
case "disabled custom binding":
|
|
require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: user.Id, ProviderId: 42, ProviderUserId: "linked"}).Error)
|
|
case "no binding":
|
|
require.NoError(t, model.DB.Model(user).Update("wechat_id", "").Error)
|
|
case "binding storage failure":
|
|
require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register("wechat_bindings_failure", func(tx *gorm.DB) {
|
|
if tx.Statement.Table == "user_oauth_bindings" {
|
|
tx.AddError(errors.New("private binding failure"))
|
|
}
|
|
}))
|
|
case "revoked session":
|
|
_, err := model.RevokeAllUserSessions(user.Id, "test")
|
|
require.NoError(t, err)
|
|
}
|
|
for _, scope := range []string{"2fa.setup", "passkey.register", "passkey.delete", "channel.key.read"} {
|
|
context := json.RawMessage(nil)
|
|
if scope == "channel.key.read" {
|
|
context = json.RawMessage(`{"channel_id":1}`)
|
|
}
|
|
_, err := service.VerifySecurityInput(identity, service.VerificationInput{Method: "session", Scope: scope, Context: context})
|
|
assert.Error(t, err, scope)
|
|
}
|
|
if scenario == "binding storage failure" {
|
|
require.NoError(t, model.DB.Callback().Query().Remove("wechat_bindings_failure"))
|
|
}
|
|
var count int64
|
|
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Count(&count).Error)
|
|
assert.Zero(t, count)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityEnrollmentMissingTargetsAreBusinessErrors(t *testing.T) {
|
|
_, identity := setupSecurityEnrollmentTest(t)
|
|
require.NoError(t, model.DB.AutoMigrate(&model.Channel{}))
|
|
for _, target := range []struct {
|
|
path, message string
|
|
handler gin.HandlerFunc
|
|
}{
|
|
{"/api/channel/999/key", "Channel does not exist", GetChannelKey},
|
|
{"/api/user/999/2fa", "User does not exist", AdminDisable2FA},
|
|
} {
|
|
response := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) {
|
|
c.Params = gin.Params{{Key: "id", Value: "999"}}
|
|
target.handler(c)
|
|
})
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
assert.False(t, body.Success)
|
|
assert.Equal(t, target.message, body.Message)
|
|
assert.NotEqual(t, "AUTH_UNAUTHORIZED", body.Code)
|
|
for _, id := range []string{"invalid", "0", "-1"} {
|
|
invalid := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) {
|
|
c.Params = gin.Params{{Key: "id", Value: id}}
|
|
target.handler(c)
|
|
})
|
|
assert.Equal(t, http.StatusOK, invalid.Code)
|
|
assert.Contains(t, invalid.Body.String(), `"success":false`)
|
|
}
|
|
require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register("target_query_failure", func(tx *gorm.DB) {
|
|
if tx.Statement.Table == "users" || tx.Statement.Table == "channels" {
|
|
tx.AddError(errors.New("private database failure"))
|
|
}
|
|
}))
|
|
failed := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) {
|
|
c.Params = gin.Params{{Key: "id", Value: "999"}}
|
|
target.handler(c)
|
|
})
|
|
require.NoError(t, model.DB.Callback().Query().Remove("target_query_failure"))
|
|
assert.Equal(t, http.StatusInternalServerError, failed.Code)
|
|
assert.NotContains(t, failed.Body.String(), "private database failure")
|
|
}
|
|
_, _, err := service.ValidateLoginSession(identity)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestSecurityEnrollmentRejectsChangedFirstFactorPolicy(t *testing.T) {
|
|
for _, provider := range []string{"telegram", "wechat"} {
|
|
for _, stage := range []string{"proof", "setup"} {
|
|
t.Run(provider+"/"+stage, func(t *testing.T) {
|
|
fixture := setupTelegramOAuthTest(t)
|
|
user, identity := fixture.user, fixture.identity
|
|
require.NoError(t, model.DB.Model(user).Update("password", "").Error)
|
|
var proof *service.SecurityProof
|
|
var err error
|
|
if provider == "telegram" {
|
|
require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error)
|
|
state, code := fixture.authorization(t, "verify", identity, "2fa.setup", telegramIdentityClaims(42))
|
|
response := telegramOAuthCallback(state, code, identity)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, response.Body.String())
|
|
require.NoError(t, common.Unmarshal(body.Data, &proof))
|
|
} else {
|
|
require.NoError(t, model.DB.Model(user).Update("wechat_id", "wechat-user").Error)
|
|
proof, err = service.VerifySecurityInput(identity, service.VerificationInput{Scope: "2fa.setup", Method: "session"})
|
|
require.Error(t, err)
|
|
assert.Nil(t, proof)
|
|
return
|
|
}
|
|
_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "passkey.register"})
|
|
assert.Error(t, err)
|
|
var setupToken string
|
|
if stage == "setup" {
|
|
response := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof.ProofToken, identity, Setup2FA)
|
|
var body securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
require.True(t, body.Success, response.Body.String())
|
|
var setup struct {
|
|
FlowToken string `json:"flow_token"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(body.Data, &setup))
|
|
setupToken = setup.FlowToken
|
|
_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"})
|
|
assert.ErrorIs(t, err, service.ErrProofConsumed)
|
|
}
|
|
if provider == "telegram" {
|
|
common.TelegramOAuthEnabled = false
|
|
} else {
|
|
require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error)
|
|
}
|
|
if stage == "proof" {
|
|
response := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof.ProofToken, identity, Setup2FA)
|
|
assert.Contains(t, response.Body.String(), `"success":false`)
|
|
assert.NotContains(t, response.Body.String(), "flow_token")
|
|
} else {
|
|
request, err := common.Marshal(map[string]string{"flow_token": setupToken, "code": "123456"})
|
|
require.NoError(t, err)
|
|
response := securityEnrollmentRequest("POST", "/api/user/2fa/enable", string(request), "", identity, Enable2FA)
|
|
assert.Contains(t, response.Body.String(), `"success":false`)
|
|
}
|
|
factor, err := model.GetTwoFAByUserId(user.Id)
|
|
require.NoError(t, err)
|
|
assert.True(t, factor == nil || !factor.IsEnabled)
|
|
})
|
|
}
|
|
}
|
|
}
|