Files
CaIon 2906e4f779 fix(oauth): require account evidence before migrating legacy GitHub bindings
Legacy GitHub bindings stored the login name, while current bindings store
the numeric account ID. A legacy match no longer signs the user in on its
own. When the account has two-factor or a passkey, the binding is rewritten
only after that verification completes, inside the transaction that issues
the session. Otherwise it is rewritten when one of the GitHub account's
verified emails matches the account email. Without either, the login is
declined with guidance to sign in another way and relink GitHub in account
settings. All-digit login names never take part in the comparison, the bind
flow no longer compares login names, and every migration or declined
attempt is recorded as an account binding audit event.
2026-09-18 23:00:37 +08:00

1126 lines
54 KiB
Go

package controller
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"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/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func newSecurityLoginPasskey(t *testing.T, userID int) *ecdsa.PrivateKey {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
credentialID := sha256.Sum256(elliptic.Marshal(key.Curve, key.X, key.Y))
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)
require.NoError(t, model.DB.Create(&model.PasskeyCredential{
UserID: userID, CredentialID: base64.StdEncoding.EncodeToString(credentialID[:]), PublicKey: base64.StdEncoding.EncodeToString(publicKey),
UserPresent: true, UserVerified: true,
}).Error)
return key
}
func beginSecurityLoginPasskey(t *testing.T, parentToken string) (string, string) {
t.Helper()
body, err := common.Marshal(map[string]string{"flow_token": parentToken})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/user/login/passkey/begin", string(body), "", service.AuthIdentity{}, LoginPasskeyBegin)
var result struct {
Success bool `json:"success"`
Data struct {
FlowToken string `json:"flow_token"`
Options struct {
PublicKey struct {
Challenge string `json:"challenge"`
UserVerification string `json:"userVerification"`
} `json:"publicKey"`
} `json:"options"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
require.Equal(t, "required", result.Data.Options.PublicKey.UserVerification)
return result.Data.FlowToken, result.Data.Options.PublicKey.Challenge
}
func TestSecurityLoginCodeCompletesOnce(t *testing.T) {
for _, test := range []struct {
path string
backup bool
}{{"/api/user/login/verify", false}, {"/api/user/login/2fa", false}, {"/api/user/login/verify", true}} {
t.Run(fmt.Sprintf("%s/backup=%t", test.path, test.backup), func(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
require.NoError(t, model.DB.Create(factor).Error)
challenge, err := service.StartLoginVerification(user, "password", nil)
require.NoError(t, err)
require.NotNil(t, challenge)
code, err := totp.GenerateCode(factor.Secret, time.Now())
require.NoError(t, err)
if test.backup {
code = "ABCD-1234"
hash, err := common.HashBackupCode(code)
require.NoError(t, err)
require.NoError(t, model.DB.Create(&model.TwoFABackupCode{UserId: user.Id, CodeHash: hash}).Error)
}
body, err := common.Marshal(map[string]string{"flow_token": challenge.FlowToken, "code": code})
require.NoError(t, err)
router := gin.New()
router.POST("/api/user/login/verify", VerifyLogin)
router.POST("/api/user/login/2fa", Verify2FALogin)
for attempt := range 2 {
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest("POST", test.path, strings.NewReader(string(body))))
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, attempt == 0, result.Success, response.Body.String())
if attempt == 0 {
var bundle service.AuthBundle
require.NoError(t, common.Unmarshal(result.Data, &bundle))
assert.NotEmpty(t, bundle.AccessToken)
assert.Equal(t, "password", bundle.Session.LoginMethod)
assert.NotEmpty(t, response.Header().Values("Set-Cookie"))
} else {
assert.Empty(t, response.Header().Values("Set-Cookie"))
}
}
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
assert.EqualValues(t, 2, count)
})
}
}
func TestSecurityLoginRejectsChangedOrExpiredAuthorization(t *testing.T) {
for _, change := range []string{"expired", "disabled user", "auth version", "factor removed", "factor locked", "legacy flow", "wrong method", "other purpose"} {
t.Run(change, func(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
require.NoError(t, model.DB.Create(factor).Error)
challenge, err := service.StartLoginVerification(user, "password", nil)
require.NoError(t, err)
method := "2fa"
switch change {
case "expired":
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeLoginVerification).Update("expires_at", time.Now().Add(-time.Minute)).Error)
case "disabled user":
require.NoError(t, model.DB.Model(user).Update("status", common.UserStatusDisabled).Error)
case "auth version":
require.NoError(t, model.DB.Model(user).Update("auth_version", user.AuthVersion+1).Error)
case "factor removed":
require.NoError(t, model.DB.Delete(factor).Error)
case "factor locked":
require.NoError(t, model.DB.Model(factor).Update("locked_until", time.Now().Add(time.Minute)).Error)
case "legacy flow":
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeLoginVerification).Update("purpose", model.AuthFlowPurposeTwoFALogin).Error)
case "wrong method":
method = "password"
case "other purpose":
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeLoginVerification).Update("purpose", model.AuthFlowPurposeSecurityProof).Error)
}
code, err := totp.GenerateCode(factor.Secret, time.Now())
require.NoError(t, err)
body, err := common.Marshal(map[string]string{"flow_token": challenge.FlowToken, "code": code, "method": method})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/user/login/verify", string(body), "", service.AuthIdentity{}, VerifyLogin)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.False(t, result.Success, change)
assert.Empty(t, response.Header().Values("Set-Cookie"))
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
assert.EqualValues(t, 1, count)
})
}
}
func TestSecurityLoginPasskeyDoesNotRequireAdditionalTwoFA(t *testing.T) {
for _, direct := range []bool{false, true} {
t.Run(fmt.Sprintf("direct=%t", direct), func(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
key := newSecurityLoginPasskey(t, user.Id)
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
for _, verified := range []bool{false, true} {
var flowToken, challenge, parentToken string
if direct {
response := securityEnrollmentRequest("POST", "/api/user/passkey/login/begin", "", "", service.AuthIdentity{}, PasskeyLoginBegin)
var result struct {
Data struct {
FlowToken string `json:"flow_token"`
Options struct {
PublicKey struct {
Challenge string `json:"challenge"`
UserVerification string `json:"userVerification"`
} `json:"publicKey"`
} `json:"options"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.Equal(t, "required", result.Data.Options.PublicKey.UserVerification)
flowToken, challenge = result.Data.FlowToken, result.Data.Options.PublicKey.Challenge
} else {
pending, err := service.StartLoginVerification(user, "oauth:github", nil)
require.NoError(t, err)
parentToken = pending.FlowToken
flowToken, challenge = beginSecurityLoginPasskey(t, parentToken)
}
var assertion map[string]any
require.NoError(t, common.Unmarshal(securityPasskeyResponse(t, key, challenge, false, 0, verified), &assertion))
if direct {
response, ok := assertion["response"].(map[string]any)
require.True(t, ok)
response["userHandle"] = base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(user.Id)))
}
payload := map[string]any{"flow_token": flowToken, "credential": assertion}
handler, path := PasskeyLoginFinish, "/api/user/passkey/login/finish"
if !direct {
payload["flow_token"], payload["passkey_flow_token"] = parentToken, flowToken
handler, path = LoginPasskeyFinish, "/api/user/login/passkey/finish"
}
body, err := common.Marshal(payload)
require.NoError(t, err)
router := gin.New()
router.POST(path, handler)
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest("POST", path, strings.NewReader(string(body))))
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.Equal(t, verified, result.Success, response.Body.String())
if verified {
var bundle service.AuthBundle
require.NoError(t, common.Unmarshal(result.Data, &bundle))
assert.NotEmpty(t, bundle.AccessToken)
} else {
assert.Empty(t, response.Header().Values("Set-Cookie"))
}
}
})
}
}
func TestSecurityLoginPasskeyConcurrentCompletionCreatesOneSession(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
key := newSecurityLoginPasskey(t, user.Id)
pending, err := service.StartLoginVerification(user, "password", nil)
require.NoError(t, err)
requests := make([]string, 2)
for index := range requests {
token, challenge := beginSecurityLoginPasskey(t, pending.FlowToken)
body, err := common.Marshal(map[string]any{
"flow_token": pending.FlowToken, "passkey_flow_token": token,
"credential": securityPasskeyResponse(t, key, challenge, false, 0),
})
require.NoError(t, err)
requests[index] = string(body)
}
start := make(chan struct{})
responses := make(chan *httptest.ResponseRecorder, 2)
var workers sync.WaitGroup
for _, body := range requests {
workers.Go(func() {
<-start
responses <- securityEnrollmentRequest("POST", "/api/user/login/passkey/finish", body, "", service.AuthIdentity{}, LoginPasskeyFinish)
})
}
close(start)
workers.Wait()
close(responses)
successes := 0
for response := range responses {
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
if result.Success {
successes++
}
}
assert.Equal(t, 1, successes)
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
assert.EqualValues(t, 2, count)
}
func TestSecurityLoginSessionFailureRollsBackChallengeConsumption(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
key := newSecurityLoginPasskey(t, user.Id)
pending, err := service.StartLoginVerification(user, "password", nil)
require.NoError(t, err)
require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("login_session_failure", func(tx *gorm.DB) {
if tx.Statement.Table == "user_sessions" {
tx.AddError(errors.New("private session creation failure"))
}
}))
t.Cleanup(func() { _ = model.DB.Callback().Create().Remove("login_session_failure") })
for _, fail := range []bool{true, false} {
token, challenge := beginSecurityLoginPasskey(t, pending.FlowToken)
body, err := common.Marshal(map[string]any{"flow_token": pending.FlowToken, "passkey_flow_token": token, "credential": securityPasskeyResponse(t, key, challenge, false, 0)})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/user/login/passkey/finish", string(body), "", service.AuthIdentity{}, LoginPasskeyFinish)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, !fail, result.Success, response.Body.String())
if fail {
assert.Empty(t, response.Header().Values("Set-Cookie"))
assert.NotContains(t, response.Body.String(), "private")
_, err = model.GetAuthFlow(pending.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeLoginVerification})
require.NoError(t, err, "a failed session transaction must leave the parent challenge usable")
require.NoError(t, model.DB.Callback().Create().Remove("login_session_failure"))
}
}
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
assert.EqualValues(t, 2, count)
}
func TestSecurityLoginFactorStateDoesNotAddPasswordLoginQueries(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
newSecurityLoginPasskey(t, user.Id)
previousPasswordLogin := common.PasswordLoginEnabled
common.PasswordLoginEnabled = true
t.Cleanup(func() { common.PasswordLoginEnabled = previousPasswordLogin })
queries := 0
require.NoError(t, model.DB.Callback().Query().After("gorm:query").Register("login_query_count", func(tx *gorm.DB) {
if !tx.DryRun {
queries++
}
}))
t.Cleanup(func() { _ = model.DB.Callback().Query().Remove("login_query_count") })
state, err := model.GetUserVerificationState(user.Id)
require.NoError(t, err)
assert.True(t, state.HasPassword)
assert.True(t, state.HasPasskey)
assert.False(t, state.HasTwoFA)
assert.Equal(t, 1, queries, "factor availability must be one database round trip")
queries = 0
response := securityEnrollmentRequest("POST", "/api/user/login", `{"username":"enrollment-user","password":"enrollment-password"}`, "", service.AuthIdentity{}, Login)
assert.Contains(t, response.Body.String(), `"require_verification":true`)
assert.Equal(t, 2, queries, "only the existing credential lookup and the replacement factor-state lookup run before the challenge")
}
type boundLoginOAuthProvider struct {
authFlowTestOAuthProvider
userID int
}
func (*boundLoginOAuthProvider) IsUserIDTaken(string) bool { return true }
func (*boundLoginOAuthProvider) ProviderUserIDColumn() string { return "github_id" }
func (provider *boundLoginOAuthProvider) FillUserByProviderID(user *model.User, _ string) error {
return model.DB.First(user, provider.userID).Error
}
func TestSecurityLoginAllPrimaryTransportsRequireAdditionalVerification(t *testing.T) {
for _, transport := range []string{"oauth", "custom oauth", "wechat", "telegram"} {
t.Run(transport, func(t *testing.T) {
var user *model.User
var telegram *telegramOAuthFixture
if transport == "telegram" {
telegram = setupTelegramOAuthTest(t)
user = telegram.user
} else {
user, _ = setupSecurityEnrollmentTest(t)
}
newSecurityLoginPasskey(t, user.Id)
var response *httptest.ResponseRecorder
switch transport {
case "telegram":
require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error)
state, code := telegram.authorization(t, "login", service.AuthIdentity{}, "", telegramIdentityClaims(42))
response = telegramOAuthCallback(state, code, service.AuthIdentity{})
case "wechat":
require.NoError(t, model.DB.Model(user).Update("wechat_id", "bound-wechat").Error)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "wechat-code", r.URL.Query().Get("code"))
_, _ = w.Write([]byte(`{"success":true,"data":"bound-wechat"}`))
}))
t.Cleanup(upstream.Close)
previousEnabled, previousAddress := common.WeChatAuthEnabled, common.WeChatServerAddress
common.WeChatAuthEnabled, common.WeChatServerAddress = true, upstream.URL
t.Cleanup(func() { common.WeChatAuthEnabled, common.WeChatServerAddress = previousEnabled, previousAddress })
response = securityEnrollmentRequest("GET", "/api/oauth/wechat?code=wechat-code", "", "", service.AuthIdentity{}, WeChatAuth)
default:
const slug = "unified-login-test"
if transport == "custom oauth" {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/token" {
_, _ = w.Write([]byte(`{"access_token":"provider-token","token_type":"Bearer"}`))
return
}
_, _ = w.Write([]byte(`{"sub":"bound-custom","name":"User"}`))
}))
t.Cleanup(upstream.Close)
oauth.RegisterCustom(slug, oauth.NewGenericOAuthProvider(&model.CustomOAuthProvider{
Id: 42, Slug: slug, Name: "Custom login", Enabled: true, ClientId: "client", ClientSecret: "secret", UserIdField: "sub",
TokenEndpoint: upstream.URL + "/token", UserInfoEndpoint: upstream.URL + "/userinfo",
}))
require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: user.Id, ProviderId: 42, ProviderUserId: "bound-custom"}).Error)
} else {
oauth.Register(slug, &boundLoginOAuthProvider{userID: user.Id})
}
t.Cleanup(func() { oauth.Unregister(slug) })
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{Purpose: model.AuthFlowPurposeOAuth, Provider: slug, Intent: model.AuthFlowIntentLogin, Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute)})
require.NoError(t, err)
router := gin.New()
router.GET("/api/oauth/:provider", HandleOAuth)
response = httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest("GET", "/api/oauth/"+slug+"?state="+token+"&code=provider-code", nil))
}
var result struct {
Success bool `json:"success"`
Data service.LoginChallenge `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
assert.True(t, result.Data.RequireVerification)
assert.Equal(t, []service.VerificationMethodOption{{Method: "passkey", Available: true}}, result.Data.Methods)
assert.NotEmpty(t, result.Data.FlowToken)
assert.Empty(t, response.Header().Values("Set-Cookie"))
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
assert.EqualValues(t, 1, count)
})
}
}
func TestSecurityLoginPasskeyCannotCompleteAnotherChallenge(t *testing.T) {
for _, otherUser := range []bool{false, true} {
t.Run(fmt.Sprintf("other-user=%t", otherUser), func(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
key := newSecurityLoginPasskey(t, user.Id)
first, err := service.StartLoginVerification(user, "password", nil)
require.NoError(t, err)
passkeyToken, challenge := beginSecurityLoginPasskey(t, first.FlowToken)
if otherUser {
user = &model.User{Username: "other-login", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "other-login", AuthVersion: 1}
require.NoError(t, model.DB.Create(user).Error)
newSecurityLoginPasskey(t, user.Id)
}
second, err := service.StartLoginVerification(user, "password", nil)
require.NoError(t, err)
body, err := common.Marshal(map[string]any{"flow_token": second.FlowToken, "passkey_flow_token": passkeyToken, "credential": securityPasskeyResponse(t, key, challenge, false, 0)})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/user/login/passkey/finish", string(body), "", service.AuthIdentity{}, LoginPasskeyFinish)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.False(t, result.Success)
assert.Empty(t, response.Header().Values("Set-Cookie"))
var count int64
require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&count).Error)
assert.EqualValues(t, 1, count)
})
}
}
func TestSecurityLoginPasskeyCanManageTwoFAWithScopedProofs(t *testing.T) {
for _, scope := range []string{service.VerificationScopeTwoFADisable, service.VerificationScopeTwoFABackupCodes} {
t.Run(scope, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
key := newSecurityLoginPasskey(t, user.Id)
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
hash, err := common.HashBackupCode("ABCD-1234")
require.NoError(t, err)
require.NoError(t, model.DB.Create(&model.TwoFABackupCode{UserId: user.Id, CodeHash: hash}).Error)
if scope == service.VerificationScopeTwoFABackupCodes {
_, err := service.VerifySecurityInput(identity, service.VerificationInput{Scope: scope, Method: "2fa", Code: "ABCD-1234"})
assert.ErrorIs(t, err, service.ErrVerificationFailed)
remaining, err := model.GetUnusedBackupCodeCount(user.Id)
require.NoError(t, err)
assert.Equal(t, 1, remaining, "backup codes cannot authorize generating replacement backup codes")
}
beginBody, err := common.Marshal(map[string]string{"scope": scope})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/user/passkey/verify/begin", string(beginBody), "", identity, PasskeyVerifyBegin)
var started struct {
Success bool `json:"success"`
Data struct {
FlowToken string `json:"flow_token"`
Options struct {
PublicKey struct {
Challenge string `json:"challenge"`
UserVerification string `json:"userVerification"`
} `json:"publicKey"`
} `json:"options"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &started))
require.True(t, started.Success, response.Body.String())
assert.Equal(t, "required", started.Data.Options.PublicKey.UserVerification)
finish, err := common.Marshal(passkeyFinishRequest{FlowToken: started.Data.FlowToken, Credential: securityPasskeyResponse(t, key, started.Data.Options.PublicKey.Challenge, false, 0)})
require.NoError(t, err)
response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/finish", string(finish), "", identity, PasskeyVerifyFinish)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
var proof service.SecurityProof
require.NoError(t, common.Unmarshal(result.Data, &proof))
handler, path := Disable2FA, "/api/user/2fa/disable"
if scope == service.VerificationScopeTwoFABackupCodes {
handler, path = RegenerateBackupCodes, "/api/user/2fa/backup_codes"
response = securityEnrollmentRequest("POST", "/api/user/2fa/disable", `{}`, proof.ProofToken, identity, Disable2FA)
assert.Contains(t, response.Body.String(), "SECURITY_PROOF_SCOPE_MISMATCH")
}
response = securityEnrollmentRequest("POST", path, `{}`, proof.ProofToken, identity, handler)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
stored, err := model.GetUserById(user.Id, false)
require.NoError(t, err)
assert.Equal(t, identity.UserAuthVersion+1, stored.AuthVersion)
_, err = model.GetPasskeyByUserID(user.Id)
require.NoError(t, err, "managing 2FA must retain the alternative Passkey factor")
factor, err := model.GetTwoFAByUserId(user.Id)
require.NoError(t, err)
if scope == service.VerificationScopeTwoFADisable {
assert.Nil(t, factor)
} else {
require.NotNil(t, factor)
assert.True(t, factor.IsEnabled)
assert.Contains(t, string(result.Data), "backup_codes")
}
response = securityEnrollmentRequest("POST", path, `{}`, proof.ProofToken, identity, handler)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.False(t, result.Success, "the proof cannot authorize a second mutation")
})
}
}
func TestSecurityLoginRegisteredPasskeyRequiresUserVerification(t *testing.T) {
for _, verified := range []bool{false, true} {
t.Run(fmt.Sprintf("verified=%t", verified), func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasskeyRegister}, service.VerificationMethodPassword)
response := securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin)
var result struct {
Success bool `json:"success"`
Data struct {
FlowToken string `json:"flow_token"`
Options struct {
PublicKey struct {
Challenge string `json:"challenge"`
AuthenticatorSelection struct {
UserVerification string `json:"userVerification"`
} `json:"authenticatorSelection"`
} `json:"publicKey"`
} `json:"options"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
assert.Equal(t, "required", result.Data.Options.PublicKey.AuthenticatorSelection.UserVerification)
body, err := common.Marshal(passkeyFinishRequest{FlowToken: result.Data.FlowToken, Credential: securityPasskeyResponse(t, key, result.Data.Options.PublicKey.Challenge, true, 0, verified)})
require.NoError(t, err)
response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(body), "", identity, PasskeyRegisterFinish)
var finished securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &finished))
assert.Equal(t, verified, finished.Success, response.Body.String())
_, err = model.GetPasskeyByUserID(user.Id)
if verified {
require.NoError(t, err)
} else {
assert.ErrorIs(t, err, model.ErrPasskeyNotFound)
}
})
}
}
func TestSecurityLoginRequiresConfiguredFactors(t *testing.T) {
for _, test := range []struct {
name string
twoFA, passkey bool
locked, disabled bool
methods []service.VerificationMethodOption
unavailable bool
}{
{name: "password without additional factors"},
{name: "passkey requires verification", passkey: true, methods: []service.VerificationMethodOption{{Method: "passkey", Available: true}}},
{name: "twofa requires verification", twoFA: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: true}}},
{name: "both factors are alternatives", twoFA: true, passkey: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: true}, {Method: "passkey", Available: true}}},
{name: "locked twofa permits passkey", twoFA: true, passkey: true, locked: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: false, Reason: service.ErrVerificationLocked.Error()}, {Method: "passkey", Available: true}}},
{name: "disabled passkey permits twofa", twoFA: true, passkey: true, disabled: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: true}, {Method: "passkey", Available: false, Reason: "Passkey authentication is disabled."}}},
{name: "only passkey disabled blocks password", passkey: true, disabled: true, unavailable: true},
{name: "both factors unavailable block password", passkey: true, twoFA: true, disabled: true, locked: true, unavailable: true},
} {
t.Run(test.name, func(t *testing.T) {
user, _ := setupSecurityEnrollmentTest(t)
previousPasswordLogin := common.PasswordLoginEnabled
common.PasswordLoginEnabled = true
t.Cleanup(func() { common.PasswordLoginEnabled = previousPasswordLogin })
if test.passkey {
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "login-key", PublicKey: "public-key"}).Error)
}
if test.twoFA {
factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
if test.locked {
until := time.Now().Add(time.Minute)
factor.LockedUntil = &until
}
require.NoError(t, model.DB.Create(factor).Error)
}
system_setting.GetPasskeySettings().Enabled = !test.disabled
before, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
response := securityEnrollmentRequest(http.MethodPost, "/api/user/login", `{"username":"enrollment-user","password":"enrollment-password"}`, "", service.AuthIdentity{}, Login)
var result struct {
Success bool `json:"success"`
Data struct {
RequireVerification bool `json:"require_verification"`
FlowToken string `json:"flow_token"`
ExpiresAt int64 `json:"expires_at"`
AccessToken string `json:"access_token"`
Methods []service.VerificationMethodOption `json:"methods"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
after, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
if test.unavailable {
assert.False(t, result.Success)
assert.Equal(t, before, after)
assert.Empty(t, response.Header().Values("Set-Cookie"))
return
}
require.True(t, result.Success, response.Body.String())
if len(test.methods) == 0 {
assert.False(t, result.Data.RequireVerification)
assert.NotEmpty(t, result.Data.AccessToken)
assert.Equal(t, before+1, after)
return
}
assert.True(t, result.Data.RequireVerification)
assert.NotEmpty(t, result.Data.FlowToken)
assert.Greater(t, result.Data.ExpiresAt, time.Now().Unix())
assert.LessOrEqual(t, result.Data.ExpiresAt, time.Now().Add(5*time.Minute).Unix())
assert.Equal(t, test.methods, result.Data.Methods)
assert.Empty(t, result.Data.AccessToken)
assert.Empty(t, response.Header().Values("Set-Cookie"))
assert.Equal(t, before, after, "a pending challenge must not create a session")
})
}
}
type authFlowTestOAuthProvider struct {
exchangeErr error
userInfoErr error
exchangeCalls int
userInfoCalls int
}
func (*authFlowTestOAuthProvider) GetName() string { return "Auth Flow Test" }
func (*authFlowTestOAuthProvider) IsEnabled() bool { return true }
func (provider *authFlowTestOAuthProvider) ExchangeToken(context.Context, string, *gin.Context) (*oauth.OAuthToken, error) {
provider.exchangeCalls++
if provider.exchangeErr != nil {
return nil, provider.exchangeErr
}
return &oauth.OAuthToken{}, nil
}
func (provider *authFlowTestOAuthProvider) GetUserInfo(context.Context, *oauth.OAuthToken) (*oauth.OAuthUser, error) {
provider.userInfoCalls++
if provider.userInfoErr != nil {
return nil, provider.userInfoErr
}
return &oauth.OAuthUser{ProviderUserID: "external-user"}, nil
}
func (*authFlowTestOAuthProvider) IsUserIDTaken(string) bool { return false }
func (*authFlowTestOAuthProvider) FillUserByProviderID(*model.User, string) error { return nil }
func (*authFlowTestOAuthProvider) SetProviderUserID(*model.User, string) {}
func (*authFlowTestOAuthProvider) GetProviderPrefix() string { return "flow_" }
func (*authFlowTestOAuthProvider) ProviderUserIDColumn() string { return "" }
func setupAuthFlowControllerTest(t *testing.T) *authFlowTestOAuthProvider {
t.Helper()
previousDB, previousLogDB := model.DB, model.LOG_DB
previousRedis := common.RedisEnabled
common.RedisEnabled = false
previousType := common.MainDatabaseType()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.AuthFlow{}, &model.User{}, &model.UserSession{}, &model.AuditLog{}))
model.DB, model.LOG_DB = db, db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
provider := &authFlowTestOAuthProvider{}
oauth.Register("auth-flow-test", provider)
t.Cleanup(func() {
oauth.Unregister("auth-flow-test")
model.DB, model.LOG_DB = previousDB, previousLogDB
common.RedisEnabled = previousRedis
common.SetMainDatabaseType(previousType)
})
return provider
}
func TestGenerateOAuthCodeCarriesAffiliateInLoginFlow(t *testing.T) {
setupAuthFlowControllerTest(t)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/oauth/state", strings.NewReader(`{"provider":"auth-flow-test","intent":"login","aff":"invite-code"}`))
c.Request.Header.Set("Content-Type", "application/json")
GenerateOAuthCode(c)
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Success bool `json:"success"`
Data struct {
FlowToken string `json:"flow_token"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
flow, err := model.GetAuthFlow(response.Data.FlowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
})
require.NoError(t, err)
var payload oauthFlowPayload
require.NoError(t, common.UnmarshalJsonStr(flow.Payload, &payload))
assert.Equal(t, "invite-code", payload.AffiliateCode)
assert.Zero(t, flow.UserId)
assert.Empty(t, flow.SessionId)
}
func TestGenerateOAuthCodeBindsFlowToAuthenticatedSession(t *testing.T) {
_, identity := setupSecurityEnrollmentTest(t)
oauth.Register("auth-flow-test", &authFlowTestOAuthProvider{})
t.Cleanup(func() { oauth.Unregister("auth-flow-test") })
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"auth-flow-test"}`)}, service.VerificationMethodPassword)
response := securityEnrollmentRequest(http.MethodPost, "/api/oauth/state", `{"provider":"auth-flow-test","intent":"bind"}`, proof, identity, GenerateOAuthCode)
var result struct {
Success bool `json:"success"`
Data struct {
FlowToken string `json:"flow_token"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
flow, err := model.GetAuthFlow(result.Data.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind, UserId: identity.UserID, SessionId: identity.SessionID})
require.NoError(t, err)
assert.Equal(t, identity.UserID, flow.UserId)
assert.Equal(t, identity.SessionID, flow.SessionId)
}
func TestOAuthLoginConsumesFlowOnlyAfterProviderIdentity(t *testing.T) {
provider := setupAuthFlowControllerTest(t)
tests := []struct {
name string
exchangeErr error
userInfoErr error
}{
{name: "exchange failure", exchangeErr: errors.New("exchange failed")},
{name: "user info failure", userInfoErr: errors.New("user info failed")},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
provider.exchangeErr = test.exchangeErr
provider.userInfoErr = test.userInfoErr
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
router := gin.New()
router.GET("/api/oauth/:provider", HandleOAuth)
request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+token+"&code=test", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
flow, err := model.GetAuthFlow(token, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
})
require.NoError(t, err)
assert.Nil(t, flow.ConsumedAt)
})
}
}
func TestOAuthLoginConsumesFlowAfterProviderIdentityAndOnProviderError(t *testing.T) {
provider := setupAuthFlowControllerTest(t)
provider.exchangeErr = nil
provider.userInfoErr = nil
successToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
Payload: `{invalid`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
router := gin.New()
router.GET("/api/oauth/:provider", HandleOAuth)
request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+successToken+"&code=test", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
_, err = model.GetAuthFlow(successToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
assert.Equal(t, 1, provider.exchangeCalls)
assert.Equal(t, 1, provider.userInfoCalls)
providerErrorToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
request = httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+providerErrorToken+"&error=access_denied", nil)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
_, err = model.GetAuthFlow(providerErrorToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
assert.Equal(t, 1, provider.exchangeCalls)
assert.Equal(t, 1, provider.userInfoCalls)
}
func TestOAuthBindProviderErrorConsumesSessionBoundFlow(t *testing.T) {
_, identity := setupSecurityEnrollmentTest(t)
provider := &authFlowTestOAuthProvider{}
oauth.Register("auth-flow-test", provider)
t.Cleanup(func() { oauth.Unregister("auth-flow-test") })
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"auth-flow-test"}`)}, service.VerificationMethodPassword)
started := securityEnrollmentRequest(http.MethodPost, "/api/oauth/state", `{"provider":"auth-flow-test","intent":"bind"}`, proof, identity, GenerateOAuthCode)
var result struct {
Data struct {
FlowToken string `json:"flow_token"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(started.Body.Bytes(), &result))
require.NotEmpty(t, result.Data.FlowToken)
response := securityEnrollmentRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+result.Data.FlowToken+"&error=access_denied&error_description=cancelled", "", "", identity, func(c *gin.Context) {
c.Params = gin.Params{{Key: "provider", Value: "auth-flow-test"}}
HandleOAuth(c)
})
assert.Equal(t, http.StatusOK, response.Code)
_, err := model.GetAuthFlow(result.Data.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
assert.Zero(t, provider.exchangeCalls)
assert.Zero(t, provider.userInfoCalls)
}
// legacyGitHubOAuthProvider follows the GitHub provider contract: the numeric
// account ID identifies the user, Extra["legacy_id"] carries the username and
// the verified email list is served on demand. Lookups go through the real
// github_id model queries.
type legacyGitHubOAuthProvider struct {
authFlowTestOAuthProvider
providerUserID string
legacyID string
verifiedEmails []string
verifiedEmailsErr error
verifiedEmailCalls int
}
func (*legacyGitHubOAuthProvider) GetName() string { return "GitHub" }
func (provider *legacyGitHubOAuthProvider) GetUserInfo(context.Context, *oauth.OAuthToken) (*oauth.OAuthUser, error) {
return &oauth.OAuthUser{ProviderUserID: provider.providerUserID, Username: provider.legacyID, Extra: map[string]any{"legacy_id": provider.legacyID}}, nil
}
func (provider *legacyGitHubOAuthProvider) GetVerifiedEmails(context.Context, *oauth.OAuthToken) ([]string, error) {
provider.verifiedEmailCalls++
return provider.verifiedEmails, provider.verifiedEmailsErr
}
func (*legacyGitHubOAuthProvider) IsUserIDTaken(providerUserID string) bool {
return model.IsGitHubIdAlreadyTaken(providerUserID)
}
func (*legacyGitHubOAuthProvider) FillUserByProviderID(user *model.User, providerUserID string) error {
user.GitHubId = providerUserID
return user.FillUserByGitHubId()
}
func (*legacyGitHubOAuthProvider) SetProviderUserID(user *model.User, providerUserID string) {
user.GitHubId = providerUserID
}
func (*legacyGitHubOAuthProvider) GetProviderPrefix() string { return "github_" }
func (*legacyGitHubOAuthProvider) ProviderUserIDColumn() string { return "github_id" }
// legacyGitHubOAuthLogin registers provider for one test and completes an OAuth
// login callback with it.
func legacyGitHubOAuthLogin(t *testing.T, provider *legacyGitHubOAuthProvider) *httptest.ResponseRecorder {
t.Helper()
const slug = "github-legacy-login-test"
oauth.Register(slug, provider)
t.Cleanup(func() { oauth.Unregister(slug) })
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{Purpose: model.AuthFlowPurposeOAuth, Provider: slug, Intent: model.AuthFlowIntentLogin, Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute)})
require.NoError(t, err)
router := gin.New()
router.GET("/api/oauth/:provider", HandleOAuth)
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/oauth/"+slug+"?state="+token+"&code=provider-code", nil))
return response
}
// legacyGitHubBindingAudits returns the account binding audit rows in creation
// order together with their encoded parameters.
func legacyGitHubBindingAudits(t *testing.T) ([]model.AuditLog, []string) {
t.Helper()
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "user.binding_bind").Order("id").Find(&audits).Error)
params := make([]string, 0, len(audits))
for _, audit := range audits {
require.NotNil(t, audit.Other.Op)
encoded, err := common.Marshal(audit.Other.Op.Params)
require.NoError(t, err)
params = append(params, string(encoded))
}
return audits, params
}
func TestOAuthLoginLegacyGitHubBindingRequiresAccountEvidence(t *testing.T) {
const declined = "This GitHub account cannot be linked to an existing account automatically, please sign in or register another way and then link GitHub in account settings"
tests := []struct {
name string
existingGitHubID string
existingDeleted bool
withoutEmail bool
legacyID string
verifiedEmails []string
verifiedEmailsErr error
registerEnabled bool
expectLogin bool
expectMigration bool
expectNewAccount bool
expectEmailCalls int
expectAuditParams string
}{
{name: "numeric legacy value registers a new account", existingGitHubID: "424242", legacyID: "424242", verifiedEmails: []string{"legacy-github@example.com"}, registerEnabled: true, expectLogin: true, expectNewAccount: true},
{name: "numeric legacy value with registration disabled", existingGitHubID: "424242", legacyID: "424242", verifiedEmails: []string{"legacy-github@example.com"}},
{name: "soft-deleted legacy row registers a new account", existingGitHubID: "octocat-legacy", existingDeleted: true, legacyID: "octocat-legacy", verifiedEmails: []string{"legacy-github@example.com"}, registerEnabled: true, expectLogin: true, expectNewAccount: true},
{
name: "verified email match migrates", existingGitHubID: "octocat-legacy", legacyID: "octocat-legacy",
verifiedEmails: []string{"other@example.com", " Legacy-GitHub@Example.com "}, expectLogin: true, expectMigration: true, expectEmailCalls: 1,
// No SMTP server is configured in tests, so the notification attempt is recorded as failed.
expectAuditParams: `{"provider":"github","legacy_migration":true,"legacy_id":"octocat-legacy","provider_user_id":"900001","verified_email_matched":true,"success":true,"notification_failed":true}`,
},
{
name: "no matching verified email declines", existingGitHubID: "octocat-legacy", legacyID: "octocat-legacy",
verifiedEmails: []string{"other@example.com"}, registerEnabled: true, expectEmailCalls: 1,
expectAuditParams: `{"provider":"github","legacy_migration":true,"legacy_id":"octocat-legacy","provider_user_id":"900001","success":false,"reason":"no_matching_evidence"}`,
},
{
name: "verified emails failure declines", existingGitHubID: "octocat-legacy", legacyID: "octocat-legacy",
verifiedEmailsErr: errors.New("emails unavailable"), registerEnabled: true, expectEmailCalls: 1,
expectAuditParams: `{"provider":"github","legacy_migration":true,"legacy_id":"octocat-legacy","provider_user_id":"900001","success":false,"reason":"verified_emails_unavailable"}`,
},
{
name: "account without email declines before asking the provider", existingGitHubID: "octocat-legacy", withoutEmail: true, legacyID: "octocat-legacy",
verifiedEmails: []string{"legacy-github@example.com"}, registerEnabled: true,
expectAuditParams: `{"provider":"github","legacy_migration":true,"legacy_id":"octocat-legacy","provider_user_id":"900001","success":false,"reason":"no_matching_evidence"}`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
setupSecurityEnrollmentTest(t)
previousRegister := common.RegisterEnabled
common.RegisterEnabled = test.registerEnabled
t.Cleanup(func() { common.RegisterEnabled = previousRegister })
existing := &model.User{Username: "legacy-github", Email: "legacy-github@example.com", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "legacy-github", AuthVersion: 1, GitHubId: test.existingGitHubID}
if test.withoutEmail {
existing.Email = ""
}
require.NoError(t, model.DB.Create(existing).Error)
if test.existingDeleted {
require.NoError(t, model.DB.Delete(existing).Error)
}
provider := &legacyGitHubOAuthProvider{providerUserID: "900001", legacyID: test.legacyID, verifiedEmails: test.verifiedEmails, verifiedEmailsErr: test.verifiedEmailsErr}
response := legacyGitHubOAuthLogin(t, provider)
var result struct {
Success bool `json:"success"`
Message string `json:"message"`
Data struct {
User struct {
Id int `json:"id"`
} `json:"user"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.Equal(t, test.expectLogin, result.Success, response.Body.String())
assert.Equal(t, test.expectEmailCalls, provider.verifiedEmailCalls)
var reloaded model.User
require.NoError(t, model.DB.Unscoped().First(&reloaded, existing.Id).Error)
audits, params := legacyGitHubBindingAudits(t)
if test.expectAuditParams == "" {
assert.Empty(t, audits)
} else {
require.Len(t, audits, 1)
assert.Equal(t, existing.Id, audits[0].UserId)
assert.Equal(t, common.RoleCommonUser, audits[0].ActorRole)
assert.Equal(t, test.expectMigration, audits[0].Success)
assert.JSONEq(t, test.expectAuditParams, params[0])
}
if test.expectMigration {
assert.Equal(t, existing.Id, result.Data.User.Id)
assert.Equal(t, "900001", reloaded.GitHubId)
assert.NotEmpty(t, response.Header().Values("Set-Cookie"))
return
}
assert.Equal(t, test.existingGitHubID, reloaded.GitHubId, "the existing binding must stay untouched")
if test.expectNewAccount {
var created model.User
require.NoError(t, model.DB.Where("github_id = ?", "900001").First(&created).Error)
assert.NotEqual(t, existing.Id, created.Id)
assert.Equal(t, created.Id, result.Data.User.Id)
return
}
assert.Empty(t, response.Header().Values("Set-Cookie"))
if test.expectAuditParams != "" {
assert.Equal(t, declined, result.Message)
}
})
}
}
func TestOAuthLoginLegacyGitHubBindingMigratesAfterLoginVerification(t *testing.T) {
for _, interference := range []string{"none", "pending id claimed by another account", "binding relinked meanwhile"} {
t.Run(interference, func(t *testing.T) {
setupSecurityEnrollmentTest(t)
existing := &model.User{Username: "legacy-github", Email: "legacy-github@example.com", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "legacy-github", AuthVersion: 1, GitHubId: "octocat-legacy"}
require.NoError(t, model.DB.Create(existing).Error)
factor := &model.TwoFA{UserId: existing.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
require.NoError(t, model.DB.Create(factor).Error)
provider := &legacyGitHubOAuthProvider{providerUserID: "900001", legacyID: "octocat-legacy", verifiedEmails: []string{"legacy-github@example.com"}}
response := legacyGitHubOAuthLogin(t, provider)
var challenge struct {
Success bool `json:"success"`
Data service.LoginChallenge `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &challenge))
require.True(t, challenge.Success, response.Body.String())
require.True(t, challenge.Data.RequireVerification)
assert.Equal(t, []service.VerificationMethodOption{{Method: "2fa", Available: true}}, challenge.Data.Methods)
assert.Empty(t, response.Header().Values("Set-Cookie"))
assert.Zero(t, provider.verifiedEmailCalls, "a second factor is checked before the provider emails")
var reloaded model.User
require.NoError(t, model.DB.First(&reloaded, existing.Id).Error)
assert.Equal(t, "octocat-legacy", reloaded.GitHubId, "the binding waits for the verification")
audits, _ := legacyGitHubBindingAudits(t)
assert.Empty(t, audits)
expectedGitHubID, expectLogin := "900001", true
switch interference {
case "pending id claimed by another account":
other := &model.User{Username: "other-github", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "other-github", AuthVersion: 1, GitHubId: "900001"}
require.NoError(t, model.DB.Create(other).Error)
expectedGitHubID, expectLogin = "octocat-legacy", false
case "binding relinked meanwhile":
require.NoError(t, model.DB.Model(existing).Update("github_id", "relinked-legacy").Error)
expectedGitHubID = "relinked-legacy"
}
code, err := totp.GenerateCode(factor.Secret, time.Now())
require.NoError(t, err)
body, err := common.Marshal(map[string]string{"flow_token": challenge.Data.FlowToken, "code": code})
require.NoError(t, err)
for attempt := range 2 {
response = securityEnrollmentRequest(http.MethodPost, "/api/user/login/verify", string(body), "", service.AuthIdentity{}, VerifyLogin)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, attempt == 0 && expectLogin, result.Success, response.Body.String())
if attempt == 0 && !expectLogin {
assert.Equal(t, "ACCOUNT_ALREADY_BOUND", result.Code)
}
if result.Success {
assert.NotEmpty(t, response.Header().Values("Set-Cookie"))
} else {
assert.Empty(t, response.Header().Values("Set-Cookie"))
}
}
require.NoError(t, model.DB.First(&reloaded, existing.Id).Error)
assert.Equal(t, expectedGitHubID, reloaded.GitHubId)
sessions, err := model.CountActiveUserSessions(existing.Id, time.Now().Unix())
require.NoError(t, err)
expectedSessions := 0
if expectLogin {
expectedSessions = 1
}
assert.EqualValues(t, expectedSessions, sessions, "the consumed challenge cannot be replayed")
audits, params := legacyGitHubBindingAudits(t)
if interference != "none" {
assert.Empty(t, audits)
return
}
require.Len(t, audits, 1)
assert.Equal(t, existing.Id, audits[0].UserId)
assert.Equal(t, common.RoleCommonUser, audits[0].ActorRole)
assert.True(t, audits[0].Success)
// No SMTP server is configured in tests, so the notification attempt is recorded as failed.
assert.JSONEq(t, `{"provider":"github","legacy_migration":true,"legacy_id":"octocat-legacy","provider_user_id":"900001","verification_method":"2fa","success":true,"notification_failed":true}`, params[0])
})
}
}
func TestOAuthBindIgnoresLegacyGitHubUsernames(t *testing.T) {
tests := []struct {
name string
ownGitHubID string
otherGitHubID string
legacyID string
expectBound bool
}{
{name: "another account's legacy username does not block binding", otherGitHubID: "octocat-legacy", legacyID: "octocat-legacy", expectBound: true},
{name: "own legacy username is replaced", ownGitHubID: "octocat-legacy", otherGitHubID: "unrelated-legacy", legacyID: "octocat-legacy", expectBound: true},
{name: "numeric legacy value does not collide", otherGitHubID: "424242", legacyID: "424242", expectBound: true},
{name: "numeric ID held by another account is rejected", otherGitHubID: "900001", legacyID: "octocat-legacy"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
if test.ownGitHubID != "" {
require.NoError(t, model.DB.Model(user).Update("github_id", test.ownGitHubID).Error)
}
other := &model.User{Username: "other-github", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "other-github", AuthVersion: 1, GitHubId: test.otherGitHubID}
require.NoError(t, model.DB.Create(other).Error)
const slug = "github-legacy-bind-test"
oauth.Register(slug, &legacyGitHubOAuthProvider{providerUserID: "900001", legacyID: test.legacyID})
t.Cleanup(func() { oauth.Unregister(slug) })
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"` + slug + `"}`)}, service.VerificationMethodPassword)
started := securityEnrollmentRequest(http.MethodPost, "/api/oauth/state", `{"provider":"`+slug+`","intent":"bind"}`, proof, identity, GenerateOAuthCode)
var flow struct {
Data struct {
FlowToken string `json:"flow_token"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(started.Body.Bytes(), &flow))
require.NotEmpty(t, flow.Data.FlowToken, started.Body.String())
response := securityEnrollmentRequest(http.MethodGet, "/api/oauth/"+slug+"?state="+flow.Data.FlowToken+"&code=provider-code", "", "", identity, func(c *gin.Context) {
c.Params = gin.Params{{Key: "provider", Value: slug}}
HandleOAuth(c)
})
var result struct {
Success bool `json:"success"`
Message string `json:"message"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, test.expectBound, result.Success, response.Body.String())
var bound, untouched model.User
require.NoError(t, model.DB.First(&bound, user.Id).Error)
require.NoError(t, model.DB.First(&untouched, other.Id).Error)
assert.Equal(t, test.otherGitHubID, untouched.GitHubId)
if test.expectBound {
assert.Equal(t, "900001", bound.GitHubId)
return
}
assert.Equal(t, test.ownGitHubID, bound.GitHubId)
assert.Equal(t, "This GitHub account has already been bound", result.Message)
})
}
}