mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 16:46:57 +08:00
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.
1126 lines
54 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|