feat: add profile auth identity binding flow
This commit is contained in:
@@ -2,6 +2,8 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -17,6 +19,7 @@ import (
|
|||||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
|
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -25,17 +28,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
linuxDoOAuthCookiePath = "/api/v1/auth/oauth/linuxdo"
|
linuxDoOAuthCookiePath = "/api/v1/auth/oauth/linuxdo"
|
||||||
linuxDoOAuthStateCookieName = "linuxdo_oauth_state"
|
oauthBindAccessTokenCookiePath = "/api/v1/auth/oauth"
|
||||||
linuxDoOAuthVerifierCookie = "linuxdo_oauth_verifier"
|
linuxDoOAuthStateCookieName = "linuxdo_oauth_state"
|
||||||
linuxDoOAuthRedirectCookie = "linuxdo_oauth_redirect"
|
linuxDoOAuthVerifierCookie = "linuxdo_oauth_verifier"
|
||||||
linuxDoOAuthCookieMaxAgeSec = 10 * 60 // 10 minutes
|
linuxDoOAuthRedirectCookie = "linuxdo_oauth_redirect"
|
||||||
linuxDoOAuthDefaultRedirectTo = "/dashboard"
|
linuxDoOAuthIntentCookieName = "linuxdo_oauth_intent"
|
||||||
linuxDoOAuthDefaultFrontendCB = "/auth/linuxdo/callback"
|
linuxDoOAuthBindUserCookieName = "linuxdo_oauth_bind_user"
|
||||||
|
oauthBindAccessTokenCookieName = "oauth_bind_access_token"
|
||||||
|
linuxDoOAuthCookieMaxAgeSec = 10 * 60 // 10 minutes
|
||||||
|
linuxDoOAuthDefaultRedirectTo = "/dashboard"
|
||||||
|
linuxDoOAuthDefaultFrontendCB = "/auth/linuxdo/callback"
|
||||||
|
|
||||||
linuxDoOAuthMaxRedirectLen = 2048
|
linuxDoOAuthMaxRedirectLen = 2048
|
||||||
linuxDoOAuthMaxFragmentValueLen = 512
|
linuxDoOAuthMaxFragmentValueLen = 512
|
||||||
linuxDoOAuthMaxSubjectLen = 64 - len("linuxdo-")
|
linuxDoOAuthMaxSubjectLen = 64 - len("linuxdo-")
|
||||||
|
|
||||||
|
oauthIntentLogin = "login"
|
||||||
|
oauthIntentBindCurrentUser = "bind_current_user"
|
||||||
)
|
)
|
||||||
|
|
||||||
type linuxDoTokenResponse struct {
|
type linuxDoTokenResponse struct {
|
||||||
@@ -96,8 +106,20 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
|
|||||||
secureCookie := isRequestHTTPS(c)
|
secureCookie := isRequestHTTPS(c)
|
||||||
setCookie(c, linuxDoOAuthStateCookieName, encodeCookieValue(state), linuxDoOAuthCookieMaxAgeSec, secureCookie)
|
setCookie(c, linuxDoOAuthStateCookieName, encodeCookieValue(state), linuxDoOAuthCookieMaxAgeSec, secureCookie)
|
||||||
setCookie(c, linuxDoOAuthRedirectCookie, encodeCookieValue(redirectTo), linuxDoOAuthCookieMaxAgeSec, secureCookie)
|
setCookie(c, linuxDoOAuthRedirectCookie, encodeCookieValue(redirectTo), linuxDoOAuthCookieMaxAgeSec, secureCookie)
|
||||||
|
intent := normalizeOAuthIntent(c.Query("intent"))
|
||||||
|
setCookie(c, linuxDoOAuthIntentCookieName, encodeCookieValue(intent), linuxDoOAuthCookieMaxAgeSec, secureCookie)
|
||||||
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
|
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
|
||||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||||
|
if intent == oauthIntentBindCurrentUser {
|
||||||
|
bindCookieValue, err := h.buildOAuthBindUserCookieFromContext(c)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCookie(c, linuxDoOAuthBindUserCookieName, encodeCookieValue(bindCookieValue), linuxDoOAuthCookieMaxAgeSec, secureCookie)
|
||||||
|
} else {
|
||||||
|
clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie)
|
||||||
|
}
|
||||||
|
|
||||||
verifier, err := oauth.GenerateCodeVerifier()
|
verifier, err := oauth.GenerateCodeVerifier()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -153,6 +175,8 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
|||||||
clearCookie(c, linuxDoOAuthStateCookieName, secureCookie)
|
clearCookie(c, linuxDoOAuthStateCookieName, secureCookie)
|
||||||
clearCookie(c, linuxDoOAuthVerifierCookie, secureCookie)
|
clearCookie(c, linuxDoOAuthVerifierCookie, secureCookie)
|
||||||
clearCookie(c, linuxDoOAuthRedirectCookie, secureCookie)
|
clearCookie(c, linuxDoOAuthRedirectCookie, secureCookie)
|
||||||
|
clearCookie(c, linuxDoOAuthIntentCookieName, secureCookie)
|
||||||
|
clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
expectedState, err := readCookieDecoded(c, linuxDoOAuthStateCookieName)
|
expectedState, err := readCookieDecoded(c, linuxDoOAuthStateCookieName)
|
||||||
@@ -171,6 +195,8 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
|||||||
redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing oauth browser session", "")
|
redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing oauth browser session", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
intent, _ := readCookieDecoded(c, linuxDoOAuthIntentCookieName)
|
||||||
|
intent = normalizeOAuthIntent(intent)
|
||||||
|
|
||||||
codeVerifier, _ := readCookieDecoded(c, linuxDoOAuthVerifierCookie)
|
codeVerifier, _ := readCookieDecoded(c, linuxDoOAuthVerifierCookie)
|
||||||
if codeVerifier == "" {
|
if codeVerifier == "" {
|
||||||
@@ -217,6 +243,40 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
|||||||
if subject != "" {
|
if subject != "" {
|
||||||
email = linuxDoSyntheticEmail(subject)
|
email = linuxDoSyntheticEmail(subject)
|
||||||
}
|
}
|
||||||
|
if intent == oauthIntentBindCurrentUser {
|
||||||
|
targetUserID, err := h.readOAuthBindUserIDFromCookie(c, linuxDoOAuthBindUserCookieName)
|
||||||
|
if err != nil {
|
||||||
|
redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth bind target", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||||||
|
Intent: oauthIntentBindCurrentUser,
|
||||||
|
Identity: service.PendingAuthIdentityKey{
|
||||||
|
ProviderType: "linuxdo",
|
||||||
|
ProviderKey: "linuxdo",
|
||||||
|
ProviderSubject: subject,
|
||||||
|
},
|
||||||
|
TargetUserID: &targetUserID,
|
||||||
|
ResolvedEmail: email,
|
||||||
|
RedirectTo: redirectTo,
|
||||||
|
BrowserSessionKey: browserSessionKey,
|
||||||
|
UpstreamIdentityClaims: map[string]any{
|
||||||
|
"email": email,
|
||||||
|
"username": username,
|
||||||
|
"subject": subject,
|
||||||
|
"suggested_display_name": displayName,
|
||||||
|
"suggested_avatar_url": avatarURL,
|
||||||
|
},
|
||||||
|
CompletionResponse: map[string]any{
|
||||||
|
"redirect": redirectTo,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth bind", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectToFrontendCallback(c, frontendCallback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired
|
// 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired
|
||||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
||||||
@@ -784,6 +844,18 @@ func clearCookie(c *gin.Context, name string, secure bool) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearOAuthBindAccessTokenCookie(c *gin.Context, secure bool) {
|
||||||
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
|
Name: oauthBindAccessTokenCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: oauthBindAccessTokenCookiePath,
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: false,
|
||||||
|
Secure: secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func truncateFragmentValue(value string) string {
|
func truncateFragmentValue(value string) string {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
@@ -842,3 +914,107 @@ func linuxDoSyntheticEmail(subject string) string {
|
|||||||
}
|
}
|
||||||
return "linuxdo-" + subject + service.LinuxDoConnectSyntheticEmailDomain
|
return "linuxdo-" + subject + service.LinuxDoConnectSyntheticEmailDomain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeOAuthIntent(raw string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
case "", oauthIntentLogin:
|
||||||
|
return oauthIntentLogin
|
||||||
|
case "bind", oauthIntentBindCurrentUser:
|
||||||
|
return oauthIntentBindCurrentUser
|
||||||
|
default:
|
||||||
|
return oauthIntentLogin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) buildOAuthBindUserCookieFromContext(c *gin.Context) (string, error) {
|
||||||
|
userID, err := h.resolveOAuthBindTargetUserID(c)
|
||||||
|
if err != nil || userID == nil || *userID <= 0 {
|
||||||
|
return "", infraerrors.Unauthorized("UNAUTHORIZED", "authentication required")
|
||||||
|
}
|
||||||
|
return buildOAuthBindUserCookieValue(*userID, h.oauthBindCookieSecret())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) resolveOAuthBindTargetUserID(c *gin.Context) (*int64, error) {
|
||||||
|
if subject, ok := servermiddleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 {
|
||||||
|
return &subject.UserID, nil
|
||||||
|
}
|
||||||
|
if h == nil || h.authService == nil || h.userService == nil {
|
||||||
|
return nil, service.ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
ck, err := c.Request.Cookie(oauthBindAccessTokenCookieName)
|
||||||
|
clearOAuthBindAccessTokenCookie(c, isRequestHTTPS(c))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString, err := url.QueryUnescape(strings.TrimSpace(ck.Value))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tokenString == "" {
|
||||||
|
return nil, service.ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := h.authService.ValidateToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user, err := h.userService.GetByID(c.Request.Context(), claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user == nil || !user.IsActive() || claims.TokenVersion != user.TokenVersion {
|
||||||
|
return nil, service.ErrInvalidToken
|
||||||
|
}
|
||||||
|
return &user.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) readOAuthBindUserIDFromCookie(c *gin.Context, cookieName string) (int64, error) {
|
||||||
|
value, err := readCookieDecoded(c, cookieName)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return parseOAuthBindUserCookieValue(value, h.oauthBindCookieSecret())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) oauthBindCookieSecret() string {
|
||||||
|
if h == nil || h.cfg == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(h.cfg.JWT.Secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOAuthBindUserCookieValue(userID int64, secret string) (string, error) {
|
||||||
|
secret = strings.TrimSpace(secret)
|
||||||
|
if userID <= 0 || secret == "" {
|
||||||
|
return "", errors.New("invalid oauth bind cookie input")
|
||||||
|
}
|
||||||
|
payload := strconv.FormatInt(userID, 10)
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
_, _ = mac.Write([]byte(payload))
|
||||||
|
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
return payload + "." + signature, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOAuthBindUserCookieValue(value string, secret string) (int64, error) {
|
||||||
|
secret = strings.TrimSpace(secret)
|
||||||
|
if secret == "" {
|
||||||
|
return 0, errors.New("missing oauth bind cookie secret")
|
||||||
|
}
|
||||||
|
payload, signature, ok := strings.Cut(strings.TrimSpace(value), ".")
|
||||||
|
if !ok || payload == "" || signature == "" {
|
||||||
|
return 0, errors.New("invalid oauth bind cookie")
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
_, _ = mac.Write([]byte(payload))
|
||||||
|
expectedSignature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
|
||||||
|
return 0, errors.New("invalid oauth bind cookie signature")
|
||||||
|
}
|
||||||
|
userID, err := strconv.ParseInt(payload, 10, 64)
|
||||||
|
if err != nil || userID <= 0 {
|
||||||
|
return 0, errors.New("invalid oauth bind cookie user")
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
|
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
|
||||||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -122,6 +124,321 @@ func TestSingleLineStripsWhitespace(t *testing.T) {
|
|||||||
require.Equal(t, "", singleLine("\n\t\r"))
|
require.Equal(t, "", singleLine("\n\t\r"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLinuxDoOAuthBindStartRedirectsAndSetsBindCookies(t *testing.T) {
|
||||||
|
handler := newLinuxDoOAuthTestHandler(t, false, config.LinuxDoConnectConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ClientID: "linuxdo-client",
|
||||||
|
ClientSecret: "linuxdo-secret",
|
||||||
|
AuthorizeURL: "https://connect.linux.do/oauth/authorize",
|
||||||
|
TokenURL: "https://connect.linux.do/oauth/token",
|
||||||
|
UserInfoURL: "https://connect.linux.do/api/user",
|
||||||
|
Scopes: "read",
|
||||||
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
|
||||||
|
FrontendRedirectURL: "/auth/linuxdo/callback",
|
||||||
|
TokenAuthMethod: "client_secret_post",
|
||||||
|
UsePKCE: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=/settings/connections", nil)
|
||||||
|
c.Request = req
|
||||||
|
c.Set(string(servermiddleware.ContextKeyUser), servermiddleware.AuthSubject{UserID: 42})
|
||||||
|
|
||||||
|
handler.LinuxDoOAuthStart(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
location := recorder.Header().Get("Location")
|
||||||
|
require.Contains(t, location, "connect.linux.do/oauth/authorize")
|
||||||
|
require.Contains(t, location, "client_id=linuxdo-client")
|
||||||
|
require.Contains(t, location, "code_challenge=")
|
||||||
|
|
||||||
|
cookies := recorder.Result().Cookies()
|
||||||
|
require.NotNil(t, findCookie(cookies, linuxDoOAuthStateCookieName))
|
||||||
|
require.NotNil(t, findCookie(cookies, linuxDoOAuthRedirectCookie))
|
||||||
|
require.NotNil(t, findCookie(cookies, linuxDoOAuthVerifierCookie))
|
||||||
|
require.NotNil(t, findCookie(cookies, oauthPendingBrowserCookieName))
|
||||||
|
|
||||||
|
intentCookie := findCookie(cookies, linuxDoOAuthIntentCookieName)
|
||||||
|
require.NotNil(t, intentCookie)
|
||||||
|
require.Equal(t, oauthIntentBindCurrentUser, decodeCookieValueForTest(t, intentCookie.Value))
|
||||||
|
|
||||||
|
bindCookie := findCookie(cookies, linuxDoOAuthBindUserCookieName)
|
||||||
|
require.NotNil(t, bindCookie)
|
||||||
|
userID, err := parseOAuthBindUserCookieValue(decodeCookieValueForTest(t, bindCookie.Value), "test-secret")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(42), userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie(t *testing.T) {
|
||||||
|
handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ClientID: "linuxdo-client",
|
||||||
|
ClientSecret: "linuxdo-secret",
|
||||||
|
AuthorizeURL: "https://connect.linux.do/oauth/authorize",
|
||||||
|
TokenURL: "https://connect.linux.do/oauth/token",
|
||||||
|
UserInfoURL: "https://connect.linux.do/api/user",
|
||||||
|
Scopes: "read",
|
||||||
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
|
||||||
|
FrontendRedirectURL: "/auth/linuxdo/callback",
|
||||||
|
TokenAuthMethod: "client_secret_post",
|
||||||
|
UsePKCE: true,
|
||||||
|
})
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
user, err := client.User.Create().
|
||||||
|
SetEmail("bind-cookie@example.com").
|
||||||
|
SetUsername("bind-cookie-user").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
token, err := handler.authService.GenerateToken(&service.User{
|
||||||
|
ID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
Username: user.Username,
|
||||||
|
PasswordHash: user.PasswordHash,
|
||||||
|
Role: user.Role,
|
||||||
|
Status: user.Status,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/start?intent=bind_current_user&redirect=/settings/connections", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: oauthBindAccessTokenCookieName, Value: token, Path: oauthBindAccessTokenCookiePath})
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.LinuxDoOAuthStart(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
|
||||||
|
bindCookie := findCookie(recorder.Result().Cookies(), linuxDoOAuthBindUserCookieName)
|
||||||
|
require.NotNil(t, bindCookie)
|
||||||
|
userID, err := parseOAuthBindUserCookieValue(decodeCookieValueForTest(t, bindCookie.Value), "test-secret")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, user.ID, userID)
|
||||||
|
|
||||||
|
accessTokenCookie := findCookie(recorder.Result().Cookies(), oauthBindAccessTokenCookieName)
|
||||||
|
require.NotNil(t, accessTokenCookie)
|
||||||
|
require.Equal(t, -1, accessTokenCookie.MaxAge)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinuxDoOAuthCallbackCreatesLoginPendingSessionForExistingUser(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
|
||||||
|
case "/userinfo":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"id":"321","username":"linuxdo_user","name":"LinuxDo Display","avatar_url":"https://cdn.example/linuxdo.png"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ClientID: "linuxdo-client",
|
||||||
|
ClientSecret: "linuxdo-secret",
|
||||||
|
AuthorizeURL: upstream.URL + "/authorize",
|
||||||
|
TokenURL: upstream.URL + "/token",
|
||||||
|
UserInfoURL: upstream.URL + "/userinfo",
|
||||||
|
Scopes: "read",
|
||||||
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
|
||||||
|
FrontendRedirectURL: "/auth/linuxdo/callback",
|
||||||
|
TokenAuthMethod: "client_secret_post",
|
||||||
|
UsePKCE: true,
|
||||||
|
})
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
existingUser, err := client.User.Create().
|
||||||
|
SetEmail(linuxDoSyntheticEmail("321")).
|
||||||
|
SetUsername("legacy-user").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-123&state=state-123", nil)
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-123"))
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-123"))
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
|
||||||
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.LinuxDoOAuthCallback(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
|
||||||
|
|
||||||
|
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
||||||
|
require.NotNil(t, sessionCookie)
|
||||||
|
|
||||||
|
session, err := client.PendingAuthSession.Query().
|
||||||
|
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, oauthIntentLogin, session.Intent)
|
||||||
|
require.NotNil(t, session.TargetUserID)
|
||||||
|
require.Equal(t, existingUser.ID, *session.TargetUserID)
|
||||||
|
require.Equal(t, linuxDoSyntheticEmail("321"), session.ResolvedEmail)
|
||||||
|
require.Equal(t, "LinuxDo Display", session.UpstreamIdentityClaims["suggested_display_name"])
|
||||||
|
|
||||||
|
completion := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
||||||
|
require.Equal(t, "/dashboard", completion["redirect"])
|
||||||
|
require.NotEmpty(t, completion["access_token"])
|
||||||
|
require.Nil(t, completion["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinuxDoOAuthCallbackCreatesInvitationPendingSessionWhenSignupRequiresInvite(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
|
||||||
|
case "/userinfo":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"id":"654","username":"linuxdo_invite","name":"Need Invite","avatar_url":"https://cdn.example/invite.png"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
handler, client := newLinuxDoOAuthHandlerAndClient(t, true, config.LinuxDoConnectConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ClientID: "linuxdo-client",
|
||||||
|
ClientSecret: "linuxdo-secret",
|
||||||
|
AuthorizeURL: upstream.URL + "/authorize",
|
||||||
|
TokenURL: upstream.URL + "/token",
|
||||||
|
UserInfoURL: upstream.URL + "/userinfo",
|
||||||
|
Scopes: "read",
|
||||||
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
|
||||||
|
FrontendRedirectURL: "/auth/linuxdo/callback",
|
||||||
|
TokenAuthMethod: "client_secret_post",
|
||||||
|
UsePKCE: true,
|
||||||
|
})
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-456&state=state-456", nil)
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-456"))
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-456"))
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
|
||||||
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-456"))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.LinuxDoOAuthCallback(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
|
||||||
|
|
||||||
|
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
||||||
|
require.NotNil(t, sessionCookie)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
session, err := client.PendingAuthSession.Query().
|
||||||
|
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, oauthIntentLogin, session.Intent)
|
||||||
|
require.Nil(t, session.TargetUserID)
|
||||||
|
|
||||||
|
completion := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
||||||
|
require.Equal(t, "invitation_required", completion["error"])
|
||||||
|
require.Equal(t, "/dashboard", completion["redirect"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinuxDoOAuthCallbackCreatesBindPendingSessionForCurrentUser(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
|
||||||
|
case "/userinfo":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"id":"999","username":"bind_user","name":"Bind Display","avatar_url":"https://cdn.example/bind.png"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ClientID: "linuxdo-client",
|
||||||
|
ClientSecret: "linuxdo-secret",
|
||||||
|
AuthorizeURL: upstream.URL + "/authorize",
|
||||||
|
TokenURL: upstream.URL + "/token",
|
||||||
|
UserInfoURL: upstream.URL + "/userinfo",
|
||||||
|
Scopes: "read",
|
||||||
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
|
||||||
|
FrontendRedirectURL: "/auth/linuxdo/callback",
|
||||||
|
TokenAuthMethod: "client_secret_post",
|
||||||
|
UsePKCE: true,
|
||||||
|
})
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
currentUser, err := client.User.Create().
|
||||||
|
SetEmail("current@example.com").
|
||||||
|
SetUsername("current-user").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-bind&state=state-bind", nil)
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-bind"))
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/settings/connections"))
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-bind"))
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentBindCurrentUser))
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthBindUserCookieName, buildEncodedOAuthBindUserCookie(t, currentUser.ID, "test-secret")))
|
||||||
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-bind"))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.LinuxDoOAuthCallback(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
|
||||||
|
|
||||||
|
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
||||||
|
require.NotNil(t, sessionCookie)
|
||||||
|
|
||||||
|
session, err := client.PendingAuthSession.Query().
|
||||||
|
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, oauthIntentBindCurrentUser, session.Intent)
|
||||||
|
require.NotNil(t, session.TargetUserID)
|
||||||
|
require.Equal(t, currentUser.ID, *session.TargetUserID)
|
||||||
|
require.Equal(t, linuxDoSyntheticEmail("999"), session.ResolvedEmail)
|
||||||
|
|
||||||
|
completion := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
||||||
|
require.Equal(t, "/settings/connections", completion["redirect"])
|
||||||
|
require.Empty(t, completion["access_token"])
|
||||||
|
require.Equal(t, "Bind Display", session.UpstreamIdentityClaims["suggested_display_name"])
|
||||||
|
|
||||||
|
userCount, err := client.User.Query().Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, userCount)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCompleteLinuxDoOAuthRegistrationAppliesPendingAdoptionDecision(t *testing.T) {
|
func TestCompleteLinuxDoOAuthRegistrationAppliesPendingAdoptionDecision(t *testing.T) {
|
||||||
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -197,3 +514,25 @@ func TestCompleteLinuxDoOAuthRegistrationAppliesPendingAdoptionDecision(t *testi
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, consumed.ConsumedAt)
|
require.NotNil(t, consumed.ConsumedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newLinuxDoOAuthTestHandler(t *testing.T, invitationEnabled bool, oauthCfg config.LinuxDoConnectConfig) *AuthHandler {
|
||||||
|
t.Helper()
|
||||||
|
handler, _ := newLinuxDoOAuthHandlerAndClient(t, invitationEnabled, oauthCfg)
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLinuxDoOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauthCfg config.LinuxDoConnectConfig) (*AuthHandler, *dbent.Client) {
|
||||||
|
t.Helper()
|
||||||
|
handler, client := newOAuthPendingFlowTestHandler(t, invitationEnabled)
|
||||||
|
handler.settingSvc = nil
|
||||||
|
handler.cfg = &config.Config{
|
||||||
|
JWT: config.JWTConfig{
|
||||||
|
Secret: "test-secret",
|
||||||
|
ExpireHour: 1,
|
||||||
|
AccessTokenExpireMinutes: 60,
|
||||||
|
RefreshTokenExpireDays: 7,
|
||||||
|
},
|
||||||
|
LinuxDo: oauthCfg,
|
||||||
|
}
|
||||||
|
return handler, client
|
||||||
|
}
|
||||||
|
|||||||
@@ -391,6 +391,16 @@ func ensurePendingOAuthIdentityForUser(ctx context.Context, tx *dbent.Tx, sessio
|
|||||||
return create.Save(ctx)
|
return create.Save(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldBindPendingOAuthIdentity(session *dbent.PendingAuthSession, decision *dbent.IdentityAdoptionDecision) bool {
|
||||||
|
if session == nil || decision == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(session.Intent), "bind_current_user") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return decision.AdoptDisplayName || decision.AdoptAvatar
|
||||||
|
}
|
||||||
|
|
||||||
func applyPendingOAuthAdoption(
|
func applyPendingOAuthAdoption(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
client *dbent.Client,
|
client *dbent.Client,
|
||||||
@@ -401,7 +411,7 @@ func applyPendingOAuthAdoption(
|
|||||||
if client == nil || session == nil || decision == nil {
|
if client == nil || session == nil || decision == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !decision.AdoptDisplayName && !decision.AdoptAvatar {
|
if !shouldBindPendingOAuthIdentity(session, decision) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,348 @@ func TestExchangePendingOAuthCompletionPreviewThenFinalizeAppliesAdoptionDecisio
|
|||||||
require.NotNil(t, consumed.ConsumedAt)
|
require.NotNil(t, consumed.ConsumedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExchangePendingOAuthCompletionBindCurrentUserPreviewThenFinalizeBindsIdentityWithoutAdoption(t *testing.T) {
|
||||||
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
userEntity, err := client.User.Create().
|
||||||
|
SetEmail("bind-target@example.com").
|
||||||
|
SetUsername("legacy-name").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
session, err := client.PendingAuthSession.Create().
|
||||||
|
SetSessionToken("bind-pending-session-token").
|
||||||
|
SetIntent("bind_current_user").
|
||||||
|
SetProviderType("linuxdo").
|
||||||
|
SetProviderKey("linuxdo").
|
||||||
|
SetProviderSubject("bind-123").
|
||||||
|
SetTargetUserID(userEntity.ID).
|
||||||
|
SetResolvedEmail(userEntity.Email).
|
||||||
|
SetBrowserSessionKey("bind-browser-session-key").
|
||||||
|
SetUpstreamIdentityClaims(map[string]any{
|
||||||
|
"username": "linuxdo_user",
|
||||||
|
"suggested_display_name": "Bound Example",
|
||||||
|
"suggested_avatar_url": "https://cdn.example/bound.png",
|
||||||
|
}).
|
||||||
|
SetLocalFlowState(map[string]any{
|
||||||
|
oauthCompletionResponseKey: map[string]any{
|
||||||
|
"access_token": "access-token",
|
||||||
|
"redirect": "/settings/profile",
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
previewRecorder := httptest.NewRecorder()
|
||||||
|
previewCtx, _ := gin.CreateTestContext(previewRecorder)
|
||||||
|
previewReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", nil)
|
||||||
|
previewReq.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
||||||
|
previewReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("bind-browser-session-key")})
|
||||||
|
previewCtx.Request = previewReq
|
||||||
|
|
||||||
|
handler.ExchangePendingOAuthCompletion(previewCtx)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, previewRecorder.Code)
|
||||||
|
previewData := decodeJSONResponseData(t, previewRecorder)
|
||||||
|
require.Equal(t, "Bound Example", previewData["suggested_display_name"])
|
||||||
|
require.Equal(t, "https://cdn.example/bound.png", previewData["suggested_avatar_url"])
|
||||||
|
require.Equal(t, true, previewData["adoption_required"])
|
||||||
|
|
||||||
|
identityCount, err := client.AuthIdentity.Query().
|
||||||
|
Where(
|
||||||
|
authidentity.ProviderTypeEQ("linuxdo"),
|
||||||
|
authidentity.ProviderKeyEQ("linuxdo"),
|
||||||
|
authidentity.ProviderSubjectEQ("bind-123"),
|
||||||
|
).
|
||||||
|
Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, identityCount)
|
||||||
|
|
||||||
|
previewSession, err := client.PendingAuthSession.Query().
|
||||||
|
Where(pendingauthsession.IDEQ(session.ID)).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, previewSession.ConsumedAt)
|
||||||
|
|
||||||
|
body := bytes.NewBufferString(`{"adopt_display_name":false,"adopt_avatar":false}`)
|
||||||
|
finalizeRecorder := httptest.NewRecorder()
|
||||||
|
finalizeCtx, _ := gin.CreateTestContext(finalizeRecorder)
|
||||||
|
finalizeReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", body)
|
||||||
|
finalizeReq.Header.Set("Content-Type", "application/json")
|
||||||
|
finalizeReq.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
||||||
|
finalizeReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("bind-browser-session-key")})
|
||||||
|
finalizeCtx.Request = finalizeReq
|
||||||
|
|
||||||
|
handler.ExchangePendingOAuthCompletion(finalizeCtx)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, finalizeRecorder.Code)
|
||||||
|
|
||||||
|
storedUser, err := client.User.Get(ctx, userEntity.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "legacy-name", storedUser.Username)
|
||||||
|
|
||||||
|
identity, err := client.AuthIdentity.Query().
|
||||||
|
Where(
|
||||||
|
authidentity.ProviderTypeEQ("linuxdo"),
|
||||||
|
authidentity.ProviderKeyEQ("linuxdo"),
|
||||||
|
authidentity.ProviderSubjectEQ("bind-123"),
|
||||||
|
).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, userEntity.ID, identity.UserID)
|
||||||
|
require.Equal(t, "Bound Example", identity.Metadata["suggested_display_name"])
|
||||||
|
require.Equal(t, "https://cdn.example/bound.png", identity.Metadata["suggested_avatar_url"])
|
||||||
|
_, hasDisplayName := identity.Metadata["display_name"]
|
||||||
|
require.False(t, hasDisplayName)
|
||||||
|
_, hasAvatarURL := identity.Metadata["avatar_url"]
|
||||||
|
require.False(t, hasAvatarURL)
|
||||||
|
|
||||||
|
decision, err := client.IdentityAdoptionDecision.Query().
|
||||||
|
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, decision.IdentityID)
|
||||||
|
require.Equal(t, identity.ID, *decision.IdentityID)
|
||||||
|
require.False(t, decision.AdoptDisplayName)
|
||||||
|
require.False(t, decision.AdoptAvatar)
|
||||||
|
|
||||||
|
consumed, err := client.PendingAuthSession.Query().
|
||||||
|
Where(pendingauthsession.IDEQ(session.ID)).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, consumed.ConsumedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangePendingOAuthCompletionBindCurrentUserOwnershipConflict(t *testing.T) {
|
||||||
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
targetUser, err := client.User.Create().
|
||||||
|
SetEmail("bind-conflict-target@example.com").
|
||||||
|
SetUsername("target-user").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ownerUser, err := client.User.Create().
|
||||||
|
SetEmail("bind-conflict-owner@example.com").
|
||||||
|
SetUsername("owner-user").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
existingIdentity, err := client.AuthIdentity.Create().
|
||||||
|
SetUserID(ownerUser.ID).
|
||||||
|
SetProviderType("linuxdo").
|
||||||
|
SetProviderKey("linuxdo").
|
||||||
|
SetProviderSubject("conflict-123").
|
||||||
|
SetMetadata(map[string]any{"username": "owner-user"}).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
session, err := client.PendingAuthSession.Create().
|
||||||
|
SetSessionToken("bind-conflict-session-token").
|
||||||
|
SetIntent("bind_current_user").
|
||||||
|
SetProviderType("linuxdo").
|
||||||
|
SetProviderKey("linuxdo").
|
||||||
|
SetProviderSubject("conflict-123").
|
||||||
|
SetTargetUserID(targetUser.ID).
|
||||||
|
SetResolvedEmail(targetUser.Email).
|
||||||
|
SetBrowserSessionKey("bind-conflict-browser-session-key").
|
||||||
|
SetUpstreamIdentityClaims(map[string]any{
|
||||||
|
"suggested_display_name": "Conflict Example",
|
||||||
|
"suggested_avatar_url": "https://cdn.example/conflict.png",
|
||||||
|
}).
|
||||||
|
SetLocalFlowState(map[string]any{
|
||||||
|
oauthCompletionResponseKey: map[string]any{
|
||||||
|
"access_token": "access-token",
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
body := bytes.NewBufferString(`{"adopt_display_name":false,"adopt_avatar":false}`)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
||||||
|
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("bind-conflict-browser-session-key")})
|
||||||
|
ginCtx.Request = req
|
||||||
|
|
||||||
|
handler.ExchangePendingOAuthCompletion(ginCtx)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusInternalServerError, recorder.Code)
|
||||||
|
payload := decodeJSONBody(t, recorder)
|
||||||
|
require.Equal(t, "PENDING_AUTH_ADOPTION_APPLY_FAILED", payload["reason"])
|
||||||
|
|
||||||
|
identity, err := client.AuthIdentity.Get(ctx, existingIdentity.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, ownerUser.ID, identity.UserID)
|
||||||
|
|
||||||
|
decision, err := client.IdentityAdoptionDecision.Query().
|
||||||
|
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, decision.IdentityID)
|
||||||
|
require.False(t, decision.AdoptDisplayName)
|
||||||
|
require.False(t, decision.AdoptAvatar)
|
||||||
|
|
||||||
|
storedSession, err := client.PendingAuthSession.Query().
|
||||||
|
Where(pendingauthsession.IDEQ(session.ID)).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, storedSession.ConsumedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangePendingOAuthCompletionLoginFalseFalseDoesNotBindIdentity(t *testing.T) {
|
||||||
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
userEntity, err := client.User.Create().
|
||||||
|
SetEmail("login-false@example.com").
|
||||||
|
SetUsername("legacy-name").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
session, err := client.PendingAuthSession.Create().
|
||||||
|
SetSessionToken("login-false-session-token").
|
||||||
|
SetIntent("login").
|
||||||
|
SetProviderType("linuxdo").
|
||||||
|
SetProviderKey("linuxdo").
|
||||||
|
SetProviderSubject("login-false-123").
|
||||||
|
SetTargetUserID(userEntity.ID).
|
||||||
|
SetResolvedEmail(userEntity.Email).
|
||||||
|
SetBrowserSessionKey("login-false-browser-session-key").
|
||||||
|
SetUpstreamIdentityClaims(map[string]any{
|
||||||
|
"suggested_display_name": "Login Example",
|
||||||
|
"suggested_avatar_url": "https://cdn.example/login.png",
|
||||||
|
}).
|
||||||
|
SetLocalFlowState(map[string]any{
|
||||||
|
oauthCompletionResponseKey: map[string]any{
|
||||||
|
"access_token": "access-token",
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
body := bytes.NewBufferString(`{"adopt_display_name":false,"adopt_avatar":false}`)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
||||||
|
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("login-false-browser-session-key")})
|
||||||
|
ginCtx.Request = req
|
||||||
|
|
||||||
|
handler.ExchangePendingOAuthCompletion(ginCtx)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
|
||||||
|
identityCount, err := client.AuthIdentity.Query().
|
||||||
|
Where(
|
||||||
|
authidentity.ProviderTypeEQ("linuxdo"),
|
||||||
|
authidentity.ProviderKeyEQ("linuxdo"),
|
||||||
|
authidentity.ProviderSubjectEQ("login-false-123"),
|
||||||
|
).
|
||||||
|
Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, identityCount)
|
||||||
|
|
||||||
|
decision, err := client.IdentityAdoptionDecision.Query().
|
||||||
|
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, decision.IdentityID)
|
||||||
|
require.False(t, decision.AdoptDisplayName)
|
||||||
|
require.False(t, decision.AdoptAvatar)
|
||||||
|
|
||||||
|
storedSession, err := client.PendingAuthSession.Query().
|
||||||
|
Where(pendingauthsession.IDEQ(session.ID)).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, storedSession.ConsumedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangePendingOAuthCompletionInvitationRequiredFalseFalsePersistsDecisionWithoutBinding(t *testing.T) {
|
||||||
|
handler, client := newOAuthPendingFlowTestHandler(t, true)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
session, err := client.PendingAuthSession.Create().
|
||||||
|
SetSessionToken("invitation-required-session-token").
|
||||||
|
SetIntent("login").
|
||||||
|
SetProviderType("linuxdo").
|
||||||
|
SetProviderKey("linuxdo").
|
||||||
|
SetProviderSubject("invitation-123").
|
||||||
|
SetBrowserSessionKey("invitation-required-browser-session-key").
|
||||||
|
SetUpstreamIdentityClaims(map[string]any{
|
||||||
|
"suggested_display_name": "Invite Example",
|
||||||
|
"suggested_avatar_url": "https://cdn.example/invite.png",
|
||||||
|
}).
|
||||||
|
SetLocalFlowState(map[string]any{
|
||||||
|
oauthCompletionResponseKey: map[string]any{
|
||||||
|
"error": "invitation_required",
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
body := bytes.NewBufferString(`{"adopt_display_name":false,"adopt_avatar":false}`)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
||||||
|
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("invitation-required-browser-session-key")})
|
||||||
|
ginCtx.Request = req
|
||||||
|
|
||||||
|
handler.ExchangePendingOAuthCompletion(ginCtx)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
data := decodeJSONResponseData(t, recorder)
|
||||||
|
require.Equal(t, "invitation_required", data["error"])
|
||||||
|
require.Equal(t, true, data["adoption_required"])
|
||||||
|
|
||||||
|
identityCount, err := client.AuthIdentity.Query().
|
||||||
|
Where(
|
||||||
|
authidentity.ProviderTypeEQ("linuxdo"),
|
||||||
|
authidentity.ProviderKeyEQ("linuxdo"),
|
||||||
|
authidentity.ProviderSubjectEQ("invitation-123"),
|
||||||
|
).
|
||||||
|
Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, identityCount)
|
||||||
|
|
||||||
|
decision, err := client.IdentityAdoptionDecision.Query().
|
||||||
|
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, decision.IdentityID)
|
||||||
|
require.False(t, decision.AdoptDisplayName)
|
||||||
|
require.False(t, decision.AdoptAvatar)
|
||||||
|
|
||||||
|
storedSession, err := client.PendingAuthSession.Query().
|
||||||
|
Where(pendingauthsession.IDEQ(session.ID)).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, storedSession.ConsumedAt)
|
||||||
|
}
|
||||||
|
|
||||||
func newOAuthPendingFlowTestHandler(t *testing.T, invitationEnabled bool) (*AuthHandler, *dbent.Client) {
|
func newOAuthPendingFlowTestHandler(t *testing.T, invitationEnabled bool) (*AuthHandler, *dbent.Client) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -198,9 +540,10 @@ func newOAuthPendingFlowTestHandler(t *testing.T, invitationEnabled bool) (*Auth
|
|||||||
service.SettingKeyInvitationCodeEnabled: boolSettingValue(invitationEnabled),
|
service.SettingKeyInvitationCodeEnabled: boolSettingValue(invitationEnabled),
|
||||||
},
|
},
|
||||||
}, cfg)
|
}, cfg)
|
||||||
|
userRepo := &oauthPendingFlowUserRepo{client: client}
|
||||||
authSvc := service.NewAuthService(
|
authSvc := service.NewAuthService(
|
||||||
client,
|
client,
|
||||||
&oauthPendingFlowUserRepo{client: client},
|
userRepo,
|
||||||
nil,
|
nil,
|
||||||
&oauthPendingFlowRefreshTokenCacheStub{},
|
&oauthPendingFlowRefreshTokenCacheStub{},
|
||||||
cfg,
|
cfg,
|
||||||
@@ -211,9 +554,11 @@ func newOAuthPendingFlowTestHandler(t *testing.T, invitationEnabled bool) (*Auth
|
|||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
userSvc := service.NewUserService(userRepo, nil, nil, nil)
|
||||||
|
|
||||||
return &AuthHandler{
|
return &AuthHandler{
|
||||||
authService: authSvc,
|
authService: authSvc,
|
||||||
|
userService: userSvc,
|
||||||
settingSvc: settingSvc,
|
settingSvc: settingSvc,
|
||||||
}, client
|
}, client
|
||||||
}
|
}
|
||||||
@@ -414,7 +759,7 @@ func (r *oauthPendingFlowUserRepo) Delete(ctx context.Context, id int64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *oauthPendingFlowUserRepo) GetUserAvatar(context.Context, int64) (*service.UserAvatar, error) {
|
func (r *oauthPendingFlowUserRepo) GetUserAvatar(context.Context, int64) (*service.UserAvatar, error) {
|
||||||
return nil, service.ErrUserNotFound
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *oauthPendingFlowUserRepo) UpsertUserAvatar(context.Context, int64, service.UpsertUserAvatarInput) (*service.UserAvatar, error) {
|
func (r *oauthPendingFlowUserRepo) UpsertUserAvatar(context.Context, int64, service.UpsertUserAvatarInput) (*service.UserAvatar, error) {
|
||||||
@@ -462,6 +807,33 @@ func (r *oauthPendingFlowUserRepo) RemoveGroupFromUserAllowedGroups(context.Cont
|
|||||||
panic("unexpected RemoveGroupFromUserAllowedGroups call")
|
panic("unexpected RemoveGroupFromUserAllowedGroups call")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *oauthPendingFlowUserRepo) ListUserAuthIdentities(ctx context.Context, userID int64) ([]service.UserAuthIdentityRecord, error) {
|
||||||
|
identities, err := r.client.AuthIdentity.Query().
|
||||||
|
Where(authidentity.UserIDEQ(userID)).
|
||||||
|
All(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
records := make([]service.UserAuthIdentityRecord, 0, len(identities))
|
||||||
|
for _, identity := range identities {
|
||||||
|
if identity == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, service.UserAuthIdentityRecord{
|
||||||
|
ProviderType: identity.ProviderType,
|
||||||
|
ProviderKey: identity.ProviderKey,
|
||||||
|
ProviderSubject: identity.ProviderSubject,
|
||||||
|
VerifiedAt: identity.VerifiedAt,
|
||||||
|
Issuer: identity.Issuer,
|
||||||
|
Metadata: identity.Metadata,
|
||||||
|
CreatedAt: identity.CreatedAt,
|
||||||
|
UpdatedAt: identity.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *oauthPendingFlowUserRepo) UpdateTotpSecret(context.Context, int64, *string) error {
|
func (r *oauthPendingFlowUserRepo) UpdateTotpSecret(context.Context, int64, *string) error {
|
||||||
panic("unexpected UpdateTotpSecret call")
|
panic("unexpected UpdateTotpSecret call")
|
||||||
}
|
}
|
||||||
|
|||||||
39
backend/internal/handler/auth_oauth_test_helpers_test.go
Normal file
39
backend/internal/handler/auth_oauth_test_helpers_test.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildEncodedOAuthBindUserCookie(t *testing.T, userID int64, secret string) string {
|
||||||
|
t.Helper()
|
||||||
|
value, err := buildOAuthBindUserCookieValue(userID, secret)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodedCookie(name, value string) *http.Cookie {
|
||||||
|
return &http.Cookie{
|
||||||
|
Name: name,
|
||||||
|
Value: encodeCookieValue(value),
|
||||||
|
Path: "/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if cookie.Name == name {
|
||||||
|
return cookie
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCookieValueForTest(t *testing.T, value string) string {
|
||||||
|
t.Helper()
|
||||||
|
decoded, err := decodeCookieValue(value)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
@@ -32,14 +32,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
oidcOAuthCookiePath = "/api/v1/auth/oauth/oidc"
|
oidcOAuthCookiePath = "/api/v1/auth/oauth/oidc"
|
||||||
oidcOAuthStateCookieName = "oidc_oauth_state"
|
oidcOAuthStateCookieName = "oidc_oauth_state"
|
||||||
oidcOAuthVerifierCookie = "oidc_oauth_verifier"
|
oidcOAuthVerifierCookie = "oidc_oauth_verifier"
|
||||||
oidcOAuthRedirectCookie = "oidc_oauth_redirect"
|
oidcOAuthRedirectCookie = "oidc_oauth_redirect"
|
||||||
oidcOAuthNonceCookie = "oidc_oauth_nonce"
|
oidcOAuthNonceCookie = "oidc_oauth_nonce"
|
||||||
oidcOAuthCookieMaxAgeSec = 10 * 60 // 10 minutes
|
oidcOAuthIntentCookieName = "oidc_oauth_intent"
|
||||||
oidcOAuthDefaultRedirectTo = "/dashboard"
|
oidcOAuthBindUserCookieName = "oidc_oauth_bind_user"
|
||||||
oidcOAuthDefaultFrontendCB = "/auth/oidc/callback"
|
oidcOAuthCookieMaxAgeSec = 10 * 60 // 10 minutes
|
||||||
|
oidcOAuthDefaultRedirectTo = "/dashboard"
|
||||||
|
oidcOAuthDefaultFrontendCB = "/auth/oidc/callback"
|
||||||
)
|
)
|
||||||
|
|
||||||
type oidcTokenResponse struct {
|
type oidcTokenResponse struct {
|
||||||
@@ -138,8 +140,20 @@ func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) {
|
|||||||
secureCookie := isRequestHTTPS(c)
|
secureCookie := isRequestHTTPS(c)
|
||||||
oidcSetCookie(c, oidcOAuthStateCookieName, encodeCookieValue(state), oidcOAuthCookieMaxAgeSec, secureCookie)
|
oidcSetCookie(c, oidcOAuthStateCookieName, encodeCookieValue(state), oidcOAuthCookieMaxAgeSec, secureCookie)
|
||||||
oidcSetCookie(c, oidcOAuthRedirectCookie, encodeCookieValue(redirectTo), oidcOAuthCookieMaxAgeSec, secureCookie)
|
oidcSetCookie(c, oidcOAuthRedirectCookie, encodeCookieValue(redirectTo), oidcOAuthCookieMaxAgeSec, secureCookie)
|
||||||
|
intent := normalizeOAuthIntent(c.Query("intent"))
|
||||||
|
oidcSetCookie(c, oidcOAuthIntentCookieName, encodeCookieValue(intent), oidcOAuthCookieMaxAgeSec, secureCookie)
|
||||||
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
|
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
|
||||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||||
|
if intent == oauthIntentBindCurrentUser {
|
||||||
|
bindCookieValue, err := h.buildOAuthBindUserCookieFromContext(c)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oidcSetCookie(c, oidcOAuthBindUserCookieName, encodeCookieValue(bindCookieValue), oidcOAuthCookieMaxAgeSec, secureCookie)
|
||||||
|
} else {
|
||||||
|
oidcClearCookie(c, oidcOAuthBindUserCookieName, secureCookie)
|
||||||
|
}
|
||||||
|
|
||||||
codeChallenge := ""
|
codeChallenge := ""
|
||||||
verifier, genErr := oauth.GenerateCodeVerifier()
|
verifier, genErr := oauth.GenerateCodeVerifier()
|
||||||
@@ -205,6 +219,8 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
|||||||
oidcClearCookie(c, oidcOAuthVerifierCookie, secureCookie)
|
oidcClearCookie(c, oidcOAuthVerifierCookie, secureCookie)
|
||||||
oidcClearCookie(c, oidcOAuthRedirectCookie, secureCookie)
|
oidcClearCookie(c, oidcOAuthRedirectCookie, secureCookie)
|
||||||
oidcClearCookie(c, oidcOAuthNonceCookie, secureCookie)
|
oidcClearCookie(c, oidcOAuthNonceCookie, secureCookie)
|
||||||
|
oidcClearCookie(c, oidcOAuthIntentCookieName, secureCookie)
|
||||||
|
oidcClearCookie(c, oidcOAuthBindUserCookieName, secureCookie)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
expectedState, err := readCookieDecoded(c, oidcOAuthStateCookieName)
|
expectedState, err := readCookieDecoded(c, oidcOAuthStateCookieName)
|
||||||
@@ -223,6 +239,8 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
|||||||
redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing oauth browser session", "")
|
redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing oauth browser session", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
intent, _ := readCookieDecoded(c, oidcOAuthIntentCookieName)
|
||||||
|
intent = normalizeOAuthIntent(intent)
|
||||||
|
|
||||||
codeVerifier := ""
|
codeVerifier := ""
|
||||||
codeVerifier, _ = readCookieDecoded(c, oidcOAuthVerifierCookie)
|
codeVerifier, _ = readCookieDecoded(c, oidcOAuthVerifierCookie)
|
||||||
@@ -324,6 +342,43 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
|||||||
idClaims.Name,
|
idClaims.Name,
|
||||||
oidcFallbackUsername(subject),
|
oidcFallbackUsername(subject),
|
||||||
)
|
)
|
||||||
|
if intent == oauthIntentBindCurrentUser {
|
||||||
|
targetUserID, err := h.readOAuthBindUserIDFromCookie(c, oidcOAuthBindUserCookieName)
|
||||||
|
if err != nil {
|
||||||
|
redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth bind target", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||||||
|
Intent: oauthIntentBindCurrentUser,
|
||||||
|
Identity: service.PendingAuthIdentityKey{
|
||||||
|
ProviderType: "oidc",
|
||||||
|
ProviderKey: issuer,
|
||||||
|
ProviderSubject: subject,
|
||||||
|
},
|
||||||
|
TargetUserID: &targetUserID,
|
||||||
|
ResolvedEmail: email,
|
||||||
|
RedirectTo: redirectTo,
|
||||||
|
BrowserSessionKey: browserSessionKey,
|
||||||
|
UpstreamIdentityClaims: map[string]any{
|
||||||
|
"email": email,
|
||||||
|
"username": username,
|
||||||
|
"subject": subject,
|
||||||
|
"issuer": issuer,
|
||||||
|
"email_verified": emailVerified != nil && *emailVerified,
|
||||||
|
"provider_fallback": strings.TrimSpace(cfg.ProviderName),
|
||||||
|
"suggested_display_name": firstNonEmpty(userInfoClaims.DisplayName, idClaims.Name, username),
|
||||||
|
"suggested_avatar_url": userInfoClaims.AvatarURL,
|
||||||
|
},
|
||||||
|
CompletionResponse: map[string]any{
|
||||||
|
"redirect": redirectTo,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth bind", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectToFrontendCallback(c, frontendCallback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired
|
// 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired
|
||||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
|
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
|
||||||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
@@ -131,6 +133,227 @@ func buildRSAJWK(kid string, pub *rsa.PublicKey) oidcJWK {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOIDCOAuthBindStartRedirectsAndSetsBindCookies(t *testing.T) {
|
||||||
|
handler := newOIDCOAuthTestHandler(t, false, config.OIDCConnectConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ClientID: "oidc-client",
|
||||||
|
ClientSecret: "oidc-secret",
|
||||||
|
IssuerURL: "https://issuer.example.com",
|
||||||
|
AuthorizeURL: "https://issuer.example.com/oauth/authorize",
|
||||||
|
TokenURL: "https://issuer.example.com/oauth/token",
|
||||||
|
UserInfoURL: "https://issuer.example.com/oauth/userinfo",
|
||||||
|
JWKSURL: "https://issuer.example.com/oauth/jwks",
|
||||||
|
Scopes: "openid profile email",
|
||||||
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/oidc/callback",
|
||||||
|
FrontendRedirectURL: "/auth/oidc/callback",
|
||||||
|
TokenAuthMethod: "client_secret_post",
|
||||||
|
UsePKCE: true,
|
||||||
|
ValidateIDToken: true,
|
||||||
|
AllowedSigningAlgs: "RS256",
|
||||||
|
ClockSkewSeconds: 120,
|
||||||
|
RequireEmailVerified: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=/settings/connections", nil)
|
||||||
|
c.Request = req
|
||||||
|
c.Set(string(servermiddleware.ContextKeyUser), servermiddleware.AuthSubject{UserID: 84})
|
||||||
|
|
||||||
|
handler.OIDCOAuthStart(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
location := recorder.Header().Get("Location")
|
||||||
|
require.Contains(t, location, "issuer.example.com/oauth/authorize")
|
||||||
|
require.Contains(t, location, "client_id=oidc-client")
|
||||||
|
require.Contains(t, location, "nonce=")
|
||||||
|
|
||||||
|
cookies := recorder.Result().Cookies()
|
||||||
|
require.NotNil(t, findCookie(cookies, oidcOAuthStateCookieName))
|
||||||
|
require.NotNil(t, findCookie(cookies, oidcOAuthRedirectCookie))
|
||||||
|
require.NotNil(t, findCookie(cookies, oidcOAuthVerifierCookie))
|
||||||
|
require.NotNil(t, findCookie(cookies, oidcOAuthNonceCookie))
|
||||||
|
require.NotNil(t, findCookie(cookies, oauthPendingBrowserCookieName))
|
||||||
|
|
||||||
|
intentCookie := findCookie(cookies, oidcOAuthIntentCookieName)
|
||||||
|
require.NotNil(t, intentCookie)
|
||||||
|
require.Equal(t, oauthIntentBindCurrentUser, decodeCookieValueForTest(t, intentCookie.Value))
|
||||||
|
|
||||||
|
bindCookie := findCookie(cookies, oidcOAuthBindUserCookieName)
|
||||||
|
require.NotNil(t, bindCookie)
|
||||||
|
userID, err := parseOAuthBindUserCookieValue(decodeCookieValueForTest(t, bindCookie.Value), "test-secret")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(84), userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCOAuthCallbackCreatesLoginPendingSessionForExistingUser(t *testing.T) {
|
||||||
|
cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
|
||||||
|
Subject: "oidc-subject-login",
|
||||||
|
PreferredUsername: "oidc_login",
|
||||||
|
DisplayName: "OIDC Login Display",
|
||||||
|
AvatarURL: "https://cdn.example/oidc-login.png",
|
||||||
|
Email: "oidc-login@example.com",
|
||||||
|
EmailVerified: true,
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
handler, client := newOIDCOAuthHandlerAndClient(t, false, cfg)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
existingUser, err := client.User.Create().
|
||||||
|
SetEmail(oidcSyntheticEmailFromIdentityKey(oidcIdentityKey(cfg.IssuerURL, "oidc-subject-login"))).
|
||||||
|
SetUsername("legacy-user").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-123", nil)
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-123"))
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-123"))
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-subject-login"))
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
|
||||||
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.OIDCOAuthCallback(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
|
||||||
|
|
||||||
|
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
||||||
|
require.NotNil(t, sessionCookie)
|
||||||
|
|
||||||
|
session, err := client.PendingAuthSession.Query().
|
||||||
|
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, oauthIntentLogin, session.Intent)
|
||||||
|
require.NotNil(t, session.TargetUserID)
|
||||||
|
require.Equal(t, existingUser.ID, *session.TargetUserID)
|
||||||
|
require.Equal(t, cfg.IssuerURL, session.ProviderKey)
|
||||||
|
require.Equal(t, "OIDC Login Display", session.UpstreamIdentityClaims["suggested_display_name"])
|
||||||
|
|
||||||
|
completion := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
||||||
|
require.Equal(t, "/dashboard", completion["redirect"])
|
||||||
|
require.NotEmpty(t, completion["access_token"])
|
||||||
|
require.Nil(t, completion["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCOAuthCallbackCreatesInvitationPendingSessionWhenSignupRequiresInvite(t *testing.T) {
|
||||||
|
cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
|
||||||
|
Subject: "oidc-subject-invite",
|
||||||
|
PreferredUsername: "oidc_invite",
|
||||||
|
DisplayName: "OIDC Invite Display",
|
||||||
|
AvatarURL: "https://cdn.example/oidc-invite.png",
|
||||||
|
Email: "oidc-invite@example.com",
|
||||||
|
EmailVerified: true,
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
handler, client := newOIDCOAuthHandlerAndClient(t, true, cfg)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-456", nil)
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-456"))
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-456"))
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-subject-invite"))
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
|
||||||
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-456"))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.OIDCOAuthCallback(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
|
||||||
|
|
||||||
|
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
||||||
|
require.NotNil(t, sessionCookie)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
session, err := client.PendingAuthSession.Query().
|
||||||
|
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, oauthIntentLogin, session.Intent)
|
||||||
|
require.Nil(t, session.TargetUserID)
|
||||||
|
|
||||||
|
completion := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
||||||
|
require.Equal(t, "invitation_required", completion["error"])
|
||||||
|
require.Equal(t, "/dashboard", completion["redirect"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCOAuthCallbackCreatesBindPendingSessionForCurrentUser(t *testing.T) {
|
||||||
|
cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
|
||||||
|
Subject: "oidc-subject-bind",
|
||||||
|
PreferredUsername: "oidc_bind",
|
||||||
|
DisplayName: "OIDC Bind Display",
|
||||||
|
AvatarURL: "https://cdn.example/oidc-bind.png",
|
||||||
|
Email: "oidc-bind@example.com",
|
||||||
|
EmailVerified: true,
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
handler, client := newOIDCOAuthHandlerAndClient(t, false, cfg)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
currentUser, err := client.User.Create().
|
||||||
|
SetEmail("current@example.com").
|
||||||
|
SetUsername("current-user").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-bind", nil)
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-bind"))
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/settings/connections"))
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-bind"))
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-subject-bind"))
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentBindCurrentUser))
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthBindUserCookieName, buildEncodedOAuthBindUserCookie(t, currentUser.ID, "test-secret")))
|
||||||
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-bind"))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.OIDCOAuthCallback(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
|
||||||
|
|
||||||
|
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
||||||
|
require.NotNil(t, sessionCookie)
|
||||||
|
|
||||||
|
session, err := client.PendingAuthSession.Query().
|
||||||
|
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, oauthIntentBindCurrentUser, session.Intent)
|
||||||
|
require.NotNil(t, session.TargetUserID)
|
||||||
|
require.Equal(t, currentUser.ID, *session.TargetUserID)
|
||||||
|
require.Equal(t, cfg.IssuerURL, session.ProviderKey)
|
||||||
|
require.Equal(t, "OIDC Bind Display", session.UpstreamIdentityClaims["suggested_display_name"])
|
||||||
|
|
||||||
|
completion := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
||||||
|
require.Equal(t, "/settings/connections", completion["redirect"])
|
||||||
|
require.Empty(t, completion["access_token"])
|
||||||
|
|
||||||
|
userCount, err := client.User.Query().Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, userCount)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCompleteOIDCOAuthRegistrationAppliesPendingAdoptionDecision(t *testing.T) {
|
func TestCompleteOIDCOAuthRegistrationAppliesPendingAdoptionDecision(t *testing.T) {
|
||||||
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -207,3 +430,116 @@ func TestCompleteOIDCOAuthRegistrationAppliesPendingAdoptionDecision(t *testing.
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, consumed.ConsumedAt)
|
require.NotNil(t, consumed.ConsumedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type oidcProviderFixture struct {
|
||||||
|
Subject string
|
||||||
|
PreferredUsername string
|
||||||
|
DisplayName string
|
||||||
|
AvatarURL string
|
||||||
|
Email string
|
||||||
|
EmailVerified bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOIDCOAuthTestHandler(t *testing.T, invitationEnabled bool, oauthCfg config.OIDCConnectConfig) *AuthHandler {
|
||||||
|
t.Helper()
|
||||||
|
handler, _ := newOIDCOAuthHandlerAndClient(t, invitationEnabled, oauthCfg)
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOIDCOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauthCfg config.OIDCConnectConfig) (*AuthHandler, *dbent.Client) {
|
||||||
|
t.Helper()
|
||||||
|
handler, client := newOAuthPendingFlowTestHandler(t, invitationEnabled)
|
||||||
|
handler.settingSvc = nil
|
||||||
|
handler.cfg = &config.Config{
|
||||||
|
JWT: config.JWTConfig{
|
||||||
|
Secret: "test-secret",
|
||||||
|
ExpireHour: 1,
|
||||||
|
AccessTokenExpireMinutes: 60,
|
||||||
|
RefreshTokenExpireDays: 7,
|
||||||
|
},
|
||||||
|
OIDC: oauthCfg,
|
||||||
|
}
|
||||||
|
return handler, client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOIDCTestProvider(t *testing.T, fixture oidcProviderFixture) (config.OIDCConnectConfig, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
kid := "test-kid"
|
||||||
|
jwks := oidcJWKSet{Keys: []oidcJWK{buildRSAJWK(kid, &privateKey.PublicKey)}}
|
||||||
|
tokenResponse := oidcTokenResponse{
|
||||||
|
AccessToken: "oidc-access-token",
|
||||||
|
TokenType: "Bearer",
|
||||||
|
ExpiresIn: 3600,
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfoPayload := map[string]any{
|
||||||
|
"sub": fixture.Subject,
|
||||||
|
"preferred_username": fixture.PreferredUsername,
|
||||||
|
"name": fixture.DisplayName,
|
||||||
|
"picture": fixture.AvatarURL,
|
||||||
|
"email": fixture.Email,
|
||||||
|
"email_verified": fixture.EmailVerified,
|
||||||
|
}
|
||||||
|
|
||||||
|
var issuer string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/token":
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(tokenResponse))
|
||||||
|
case "/userinfo":
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(userInfoPayload))
|
||||||
|
case "/jwks":
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(jwks))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
issuer = server.URL
|
||||||
|
now := time.Now()
|
||||||
|
claims := oidcIDTokenClaims{
|
||||||
|
Email: fixture.Email,
|
||||||
|
EmailVerified: boolPtr(fixture.EmailVerified),
|
||||||
|
PreferredUsername: fixture.PreferredUsername,
|
||||||
|
Name: fixture.DisplayName,
|
||||||
|
Nonce: "nonce-" + fixture.Subject,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: issuer,
|
||||||
|
Subject: fixture.Subject,
|
||||||
|
Audience: jwt.ClaimStrings{"oidc-client"},
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now.Add(-30 * time.Second)),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
token.Header["kid"] = kid
|
||||||
|
tokenResponse.IDToken, err = token.SignedString(privateKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg := config.OIDCConnectConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ProviderName: "Test OIDC",
|
||||||
|
ClientID: "oidc-client",
|
||||||
|
ClientSecret: "oidc-secret",
|
||||||
|
IssuerURL: issuer,
|
||||||
|
AuthorizeURL: issuer + "/authorize",
|
||||||
|
TokenURL: issuer + "/token",
|
||||||
|
UserInfoURL: issuer + "/userinfo",
|
||||||
|
JWKSURL: issuer + "/jwks",
|
||||||
|
Scopes: "openid profile email",
|
||||||
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/oidc/callback",
|
||||||
|
FrontendRedirectURL: "/auth/oidc/callback",
|
||||||
|
TokenAuthMethod: "client_secret_post",
|
||||||
|
UsePKCE: true,
|
||||||
|
ValidateIDToken: true,
|
||||||
|
AllowedSigningAlgs: "RS256",
|
||||||
|
ClockSkewSeconds: 120,
|
||||||
|
RequireEmailVerified: false,
|
||||||
|
}
|
||||||
|
return cfg, server.Close
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
|
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||||
|
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
|
||||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
@@ -27,6 +30,7 @@ const (
|
|||||||
wechatOAuthRedirectCookieName = "wechat_oauth_redirect"
|
wechatOAuthRedirectCookieName = "wechat_oauth_redirect"
|
||||||
wechatOAuthIntentCookieName = "wechat_oauth_intent"
|
wechatOAuthIntentCookieName = "wechat_oauth_intent"
|
||||||
wechatOAuthModeCookieName = "wechat_oauth_mode"
|
wechatOAuthModeCookieName = "wechat_oauth_mode"
|
||||||
|
wechatOAuthBindUserCookieName = "wechat_oauth_bind_user"
|
||||||
wechatOAuthDefaultRedirectTo = "/dashboard"
|
wechatOAuthDefaultRedirectTo = "/dashboard"
|
||||||
wechatOAuthDefaultFrontendCB = "/auth/wechat/callback"
|
wechatOAuthDefaultFrontendCB = "/auth/wechat/callback"
|
||||||
wechatOAuthProviderKey = "wechat-main"
|
wechatOAuthProviderKey = "wechat-main"
|
||||||
@@ -105,6 +109,16 @@ func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
|
|||||||
wechatSetCookie(c, wechatOAuthModeCookieName, encodeCookieValue(cfg.mode), wechatOAuthCookieMaxAgeSec, secureCookie)
|
wechatSetCookie(c, wechatOAuthModeCookieName, encodeCookieValue(cfg.mode), wechatOAuthCookieMaxAgeSec, secureCookie)
|
||||||
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
|
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
|
||||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||||
|
if intent == oauthIntentBindCurrentUser {
|
||||||
|
bindCookieValue, err := h.buildOAuthBindUserCookieFromContext(c)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wechatSetCookie(c, wechatOAuthBindUserCookieName, encodeCookieValue(bindCookieValue), wechatOAuthCookieMaxAgeSec, secureCookie)
|
||||||
|
} else {
|
||||||
|
wechatClearCookie(c, wechatOAuthBindUserCookieName, secureCookie)
|
||||||
|
}
|
||||||
|
|
||||||
authURL, err := buildWeChatAuthorizeURL(cfg, state)
|
authURL, err := buildWeChatAuthorizeURL(cfg, state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -138,6 +152,7 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
|
|||||||
wechatClearCookie(c, wechatOAuthRedirectCookieName, secureCookie)
|
wechatClearCookie(c, wechatOAuthRedirectCookieName, secureCookie)
|
||||||
wechatClearCookie(c, wechatOAuthIntentCookieName, secureCookie)
|
wechatClearCookie(c, wechatOAuthIntentCookieName, secureCookie)
|
||||||
wechatClearCookie(c, wechatOAuthModeCookieName, secureCookie)
|
wechatClearCookie(c, wechatOAuthModeCookieName, secureCookie)
|
||||||
|
wechatClearCookie(c, wechatOAuthBindUserCookieName, secureCookie)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
expectedState, err := readCookieDecoded(c, wechatOAuthStateCookieName)
|
expectedState, err := readCookieDecoded(c, wechatOAuthStateCookieName)
|
||||||
@@ -193,13 +208,33 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
|
|||||||
"openid": openid,
|
"openid": openid,
|
||||||
"unionid": unionid,
|
"unionid": unionid,
|
||||||
"mode": cfg.mode,
|
"mode": cfg.mode,
|
||||||
|
"channel": cfg.mode,
|
||||||
|
"channel_app_id": strings.TrimSpace(cfg.appID),
|
||||||
|
"channel_subject": openid,
|
||||||
"suggested_display_name": strings.TrimSpace(userInfo.Nickname),
|
"suggested_display_name": strings.TrimSpace(userInfo.Nickname),
|
||||||
"suggested_avatar_url": strings.TrimSpace(userInfo.HeadImgURL),
|
"suggested_avatar_url": strings.TrimSpace(userInfo.HeadImgURL),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalizedIntent := normalizeWeChatOAuthIntent(intent)
|
||||||
|
if normalizedIntent == wechatOAuthIntentBind {
|
||||||
|
if err := h.createWeChatBindPendingSession(c, cfg, providerSubject, openid, redirectTo, browserSessionKey, upstreamClaims); err != nil {
|
||||||
|
switch infraerrors.Code(err) {
|
||||||
|
case http.StatusConflict:
|
||||||
|
redirectOAuthError(c, frontendCallback, "ownership_conflict", infraerrors.Reason(err), infraerrors.Message(err))
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
redirectOAuthError(c, frontendCallback, "auth_required", infraerrors.Reason(err), infraerrors.Message(err))
|
||||||
|
default:
|
||||||
|
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectToFrontendCallback(c, frontendCallback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err := h.createWeChatPendingSession(c, normalizeWeChatOAuthIntent(intent), providerSubject, email, redirectTo, browserSessionKey, upstreamClaims, tokenPair, err); err != nil {
|
if err := h.createWeChatPendingSession(c, normalizedIntent, providerSubject, email, redirectTo, browserSessionKey, upstreamClaims, tokenPair, err, nil); err != nil {
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -207,7 +242,7 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.createWeChatPendingSession(c, normalizeWeChatOAuthIntent(intent), providerSubject, email, redirectTo, browserSessionKey, upstreamClaims, tokenPair, nil); err != nil {
|
if err := h.createWeChatPendingSession(c, normalizedIntent, providerSubject, email, redirectTo, browserSessionKey, upstreamClaims, tokenPair, nil, nil); err != nil {
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -309,6 +344,7 @@ func (h *AuthHandler) createWeChatPendingSession(
|
|||||||
upstreamClaims map[string]any,
|
upstreamClaims map[string]any,
|
||||||
tokenPair *service.TokenPair,
|
tokenPair *service.TokenPair,
|
||||||
authErr error,
|
authErr error,
|
||||||
|
targetUserID *int64,
|
||||||
) error {
|
) error {
|
||||||
completionResponse := map[string]any{
|
completionResponse := map[string]any{
|
||||||
"redirect": redirectTo,
|
"redirect": redirectTo,
|
||||||
@@ -333,6 +369,7 @@ func (h *AuthHandler) createWeChatPendingSession(
|
|||||||
ProviderKey: wechatOAuthProviderKey,
|
ProviderKey: wechatOAuthProviderKey,
|
||||||
ProviderSubject: providerSubject,
|
ProviderSubject: providerSubject,
|
||||||
},
|
},
|
||||||
|
TargetUserID: targetUserID,
|
||||||
ResolvedEmail: email,
|
ResolvedEmail: email,
|
||||||
RedirectTo: redirectTo,
|
RedirectTo: redirectTo,
|
||||||
BrowserSessionKey: browserSessionKey,
|
BrowserSessionKey: browserSessionKey,
|
||||||
@@ -341,6 +378,106 @@ func (h *AuthHandler) createWeChatPendingSession(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) createWeChatBindPendingSession(
|
||||||
|
c *gin.Context,
|
||||||
|
cfg wechatOAuthConfig,
|
||||||
|
providerSubject string,
|
||||||
|
channelSubject string,
|
||||||
|
redirectTo string,
|
||||||
|
browserSessionKey string,
|
||||||
|
upstreamClaims map[string]any,
|
||||||
|
) error {
|
||||||
|
currentUser, err := h.readOAuthBindTargetUser(c, wechatOAuthBindUserCookieName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := h.ensureWeChatBindOwnership(c.Request.Context(), currentUser.ID, providerSubject, cfg, channelSubject); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return h.createWeChatPendingSession(
|
||||||
|
c,
|
||||||
|
wechatOAuthIntentBind,
|
||||||
|
providerSubject,
|
||||||
|
currentUser.Email,
|
||||||
|
redirectTo,
|
||||||
|
browserSessionKey,
|
||||||
|
upstreamClaims,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
¤tUser.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) readOAuthBindTargetUser(c *gin.Context, cookieName string) (*dbent.User, error) {
|
||||||
|
client := h.entClient()
|
||||||
|
if client == nil {
|
||||||
|
return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
|
||||||
|
}
|
||||||
|
userID, err := h.readOAuthBindUserIDFromCookie(c, cookieName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, infraerrors.Unauthorized("AUTH_REQUIRED", "current user is required to bind wechat account")
|
||||||
|
}
|
||||||
|
userEntity, err := client.User.Get(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
if dbent.IsNotFound(err) {
|
||||||
|
return nil, infraerrors.Unauthorized("AUTH_REQUIRED", "current user is required to bind wechat account")
|
||||||
|
}
|
||||||
|
return nil, infraerrors.InternalServer("WECHAT_BIND_USER_LOOKUP_FAILED", "failed to load current user").WithCause(err)
|
||||||
|
}
|
||||||
|
return userEntity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) ensureWeChatBindOwnership(
|
||||||
|
ctx context.Context,
|
||||||
|
userID int64,
|
||||||
|
providerSubject string,
|
||||||
|
cfg wechatOAuthConfig,
|
||||||
|
channelSubject string,
|
||||||
|
) error {
|
||||||
|
client := h.entClient()
|
||||||
|
if client == nil {
|
||||||
|
return infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
identity, err := client.AuthIdentity.Query().
|
||||||
|
Where(
|
||||||
|
authidentity.ProviderTypeEQ("wechat"),
|
||||||
|
authidentity.ProviderKeyEQ(wechatOAuthProviderKey),
|
||||||
|
authidentity.ProviderSubjectEQ(strings.TrimSpace(providerSubject)),
|
||||||
|
).
|
||||||
|
Only(ctx)
|
||||||
|
if err != nil && !dbent.IsNotFound(err) {
|
||||||
|
return infraerrors.InternalServer("WECHAT_BIND_LOOKUP_FAILED", "failed to inspect wechat identity ownership").WithCause(err)
|
||||||
|
}
|
||||||
|
if identity != nil && identity.UserID != userID {
|
||||||
|
return infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user")
|
||||||
|
}
|
||||||
|
|
||||||
|
channelSubject = strings.TrimSpace(channelSubject)
|
||||||
|
channelAppID := strings.TrimSpace(cfg.appID)
|
||||||
|
if channelSubject == "" || channelAppID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
channel, err := client.AuthIdentityChannel.Query().
|
||||||
|
Where(
|
||||||
|
authidentitychannel.ProviderTypeEQ("wechat"),
|
||||||
|
authidentitychannel.ProviderKeyEQ(wechatOAuthProviderKey),
|
||||||
|
authidentitychannel.ChannelEQ(strings.TrimSpace(cfg.mode)),
|
||||||
|
authidentitychannel.ChannelAppIDEQ(channelAppID),
|
||||||
|
authidentitychannel.ChannelSubjectEQ(channelSubject),
|
||||||
|
).
|
||||||
|
WithIdentity().
|
||||||
|
Only(ctx)
|
||||||
|
if err != nil && !dbent.IsNotFound(err) {
|
||||||
|
return infraerrors.InternalServer("WECHAT_BIND_CHANNEL_LOOKUP_FAILED", "failed to inspect wechat identity channel ownership").WithCause(err)
|
||||||
|
}
|
||||||
|
if channel != nil && channel.Edges.Identity != nil && channel.Edges.Identity.UserID != userID {
|
||||||
|
return infraerrors.Conflict("AUTH_IDENTITY_CHANNEL_OWNERSHIP_CONFLICT", "auth identity channel already belongs to another user")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string, c *gin.Context) (wechatOAuthConfig, error) {
|
func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string, c *gin.Context) (wechatOAuthConfig, error) {
|
||||||
mode, err := resolveWeChatOAuthMode(rawMode, c)
|
mode, err := resolveWeChatOAuthMode(rawMode, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -121,6 +122,298 @@ func TestWeChatOAuthCallbackCreatesPendingSessionForUnifiedFlow(t *testing.T) {
|
|||||||
require.Equal(t, "openid-123", session.UpstreamIdentityClaims["openid"])
|
require.Equal(t, "openid-123", session.UpstreamIdentityClaims["openid"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
mode string
|
||||||
|
appIDEnv string
|
||||||
|
appID string
|
||||||
|
appSecret string
|
||||||
|
openID string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "open",
|
||||||
|
mode: "open",
|
||||||
|
appIDEnv: "WECHAT_OAUTH_OPEN_APP_ID",
|
||||||
|
appID: "wx-open-app",
|
||||||
|
appSecret: "wx-open-secret",
|
||||||
|
openID: "openid-open-123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mp",
|
||||||
|
mode: "mp",
|
||||||
|
appIDEnv: "WECHAT_OAUTH_MP_APP_ID",
|
||||||
|
appID: "wx-mp-app",
|
||||||
|
appSecret: "wx-mp-secret",
|
||||||
|
openID: "openid-mp-123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Setenv(tc.appIDEnv, tc.appID)
|
||||||
|
switch tc.mode {
|
||||||
|
case "open":
|
||||||
|
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", tc.appSecret)
|
||||||
|
case "mp":
|
||||||
|
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", tc.appSecret)
|
||||||
|
}
|
||||||
|
t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "/auth/wechat/callback")
|
||||||
|
|
||||||
|
originalAccessTokenURL := wechatOAuthAccessTokenURL
|
||||||
|
originalUserInfoURL := wechatOAuthUserInfoURL
|
||||||
|
t.Cleanup(func() {
|
||||||
|
wechatOAuthAccessTokenURL = originalAccessTokenURL
|
||||||
|
wechatOAuthUserInfoURL = originalUserInfoURL
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(r.URL.Path, "/sns/oauth2/access_token"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"wechat-access","openid":"` + tc.openID + `","unionid":"union-456","scope":"snsapi_login"}`))
|
||||||
|
case strings.Contains(r.URL.Path, "/sns/userinfo"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"openid":"` + tc.openID + `","unionid":"union-456","nickname":"Bind Nick","headimgurl":"https://cdn.example/bind.png"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token"
|
||||||
|
wechatOAuthUserInfoURL = upstream.URL + "/sns/userinfo"
|
||||||
|
|
||||||
|
handler, client := newWeChatOAuthTestHandler(t, false)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
currentUser, err := client.User.Create().
|
||||||
|
SetEmail("current@example.com").
|
||||||
|
SetUsername("current-user").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/callback?code=wechat-code&state=state-123", nil)
|
||||||
|
req.Host = "api.example.com"
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthStateCookieName, "state-123"))
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthRedirectCookieName, "/dashboard"))
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthIntentCookieName, wechatOAuthIntentBind))
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthModeCookieName, tc.mode))
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthBindUserCookieName, buildEncodedOAuthBindUserCookie(t, currentUser.ID, "test-secret")))
|
||||||
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.WeChatOAuthCallback(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
require.Equal(t, "/auth/wechat/callback", recorder.Header().Get("Location"))
|
||||||
|
|
||||||
|
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
||||||
|
require.NotNil(t, sessionCookie)
|
||||||
|
|
||||||
|
session, err := client.PendingAuthSession.Query().
|
||||||
|
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
||||||
|
Only(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, wechatOAuthIntentBind, session.Intent)
|
||||||
|
require.NotNil(t, session.TargetUserID)
|
||||||
|
require.Equal(t, currentUser.ID, *session.TargetUserID)
|
||||||
|
require.Equal(t, currentUser.Email, session.ResolvedEmail)
|
||||||
|
require.Equal(t, "union-456", session.ProviderSubject)
|
||||||
|
require.Equal(t, "union-456", session.UpstreamIdentityClaims["subject"])
|
||||||
|
require.Equal(t, "union-456", session.UpstreamIdentityClaims["unionid"])
|
||||||
|
require.Equal(t, tc.openID, session.UpstreamIdentityClaims["openid"])
|
||||||
|
require.Equal(t, tc.mode, session.UpstreamIdentityClaims["channel"])
|
||||||
|
require.Equal(t, tc.appID, session.UpstreamIdentityClaims["channel_app_id"])
|
||||||
|
require.Equal(t, tc.openID, session.UpstreamIdentityClaims["channel_subject"])
|
||||||
|
|
||||||
|
completionResponse := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
||||||
|
require.Equal(t, "/dashboard", completionResponse["redirect"])
|
||||||
|
_, hasAccessToken := completionResponse["access_token"]
|
||||||
|
require.False(t, hasAccessToken)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeChatOAuthCallbackBindRejectsCanonicalOwnershipConflict(t *testing.T) {
|
||||||
|
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
|
||||||
|
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
|
||||||
|
t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "/auth/wechat/callback")
|
||||||
|
|
||||||
|
originalAccessTokenURL := wechatOAuthAccessTokenURL
|
||||||
|
originalUserInfoURL := wechatOAuthUserInfoURL
|
||||||
|
t.Cleanup(func() {
|
||||||
|
wechatOAuthAccessTokenURL = originalAccessTokenURL
|
||||||
|
wechatOAuthUserInfoURL = originalUserInfoURL
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(r.URL.Path, "/sns/oauth2/access_token"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"wechat-access","openid":"openid-123","unionid":"union-456","scope":"snsapi_login"}`))
|
||||||
|
case strings.Contains(r.URL.Path, "/sns/userinfo"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"openid":"openid-123","unionid":"union-456","nickname":"Conflict Nick","headimgurl":"https://cdn.example/conflict.png"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token"
|
||||||
|
wechatOAuthUserInfoURL = upstream.URL + "/sns/userinfo"
|
||||||
|
|
||||||
|
handler, client := newWeChatOAuthTestHandler(t, false)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
owner, err := client.User.Create().
|
||||||
|
SetEmail("owner@example.com").
|
||||||
|
SetUsername("owner").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
currentUser, err := client.User.Create().
|
||||||
|
SetEmail("current@example.com").
|
||||||
|
SetUsername("current").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = client.AuthIdentity.Create().
|
||||||
|
SetUserID(owner.ID).
|
||||||
|
SetProviderType("wechat").
|
||||||
|
SetProviderKey(wechatOAuthProviderKey).
|
||||||
|
SetProviderSubject("union-456").
|
||||||
|
SetMetadata(map[string]any{"unionid": "union-456"}).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/callback?code=wechat-code&state=state-123", nil)
|
||||||
|
req.Host = "api.example.com"
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthStateCookieName, "state-123"))
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthRedirectCookieName, "/dashboard"))
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthIntentCookieName, wechatOAuthIntentBind))
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthModeCookieName, "open"))
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthBindUserCookieName, buildEncodedOAuthBindUserCookie(t, currentUser.ID, "test-secret")))
|
||||||
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.WeChatOAuthCallback(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
require.Nil(t, findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName))
|
||||||
|
assertOAuthRedirectError(t, recorder.Header().Get("Location"), "ownership_conflict", "AUTH_IDENTITY_OWNERSHIP_CONFLICT")
|
||||||
|
|
||||||
|
count, err := client.PendingAuthSession.Query().Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeChatOAuthCallbackBindRejectsChannelOwnershipConflict(t *testing.T) {
|
||||||
|
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
|
||||||
|
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
|
||||||
|
t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "/auth/wechat/callback")
|
||||||
|
|
||||||
|
originalAccessTokenURL := wechatOAuthAccessTokenURL
|
||||||
|
originalUserInfoURL := wechatOAuthUserInfoURL
|
||||||
|
t.Cleanup(func() {
|
||||||
|
wechatOAuthAccessTokenURL = originalAccessTokenURL
|
||||||
|
wechatOAuthUserInfoURL = originalUserInfoURL
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(r.URL.Path, "/sns/oauth2/access_token"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"wechat-access","openid":"openid-123","unionid":"union-456","scope":"snsapi_login"}`))
|
||||||
|
case strings.Contains(r.URL.Path, "/sns/userinfo"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"openid":"openid-123","unionid":"union-456","nickname":"Conflict Nick","headimgurl":"https://cdn.example/conflict.png"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token"
|
||||||
|
wechatOAuthUserInfoURL = upstream.URL + "/sns/userinfo"
|
||||||
|
|
||||||
|
handler, client := newWeChatOAuthTestHandler(t, false)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
owner, err := client.User.Create().
|
||||||
|
SetEmail("owner@example.com").
|
||||||
|
SetUsername("owner").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
currentUser, err := client.User.Create().
|
||||||
|
SetEmail("current@example.com").
|
||||||
|
SetUsername("current").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ownerIdentity, err := client.AuthIdentity.Create().
|
||||||
|
SetUserID(owner.ID).
|
||||||
|
SetProviderType("wechat").
|
||||||
|
SetProviderKey(wechatOAuthProviderKey).
|
||||||
|
SetProviderSubject("union-owner").
|
||||||
|
SetMetadata(map[string]any{"unionid": "union-owner"}).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = client.AuthIdentityChannel.Create().
|
||||||
|
SetIdentityID(ownerIdentity.ID).
|
||||||
|
SetProviderType("wechat").
|
||||||
|
SetProviderKey(wechatOAuthProviderKey).
|
||||||
|
SetChannel("open").
|
||||||
|
SetChannelAppID("wx-open-app").
|
||||||
|
SetChannelSubject("openid-123").
|
||||||
|
SetMetadata(map[string]any{"openid": "openid-123"}).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/callback?code=wechat-code&state=state-123", nil)
|
||||||
|
req.Host = "api.example.com"
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthStateCookieName, "state-123"))
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthRedirectCookieName, "/dashboard"))
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthIntentCookieName, wechatOAuthIntentBind))
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthModeCookieName, "open"))
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthBindUserCookieName, buildEncodedOAuthBindUserCookie(t, currentUser.ID, "test-secret")))
|
||||||
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.WeChatOAuthCallback(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
require.Nil(t, findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName))
|
||||||
|
assertOAuthRedirectError(t, recorder.Header().Get("Location"), "ownership_conflict", "AUTH_IDENTITY_CHANNEL_OWNERSHIP_CONFLICT")
|
||||||
|
|
||||||
|
count, err := client.PendingAuthSession.Query().Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, count)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCompleteWeChatOAuthRegistrationAfterInvitationPendingSession(t *testing.T) {
|
func TestCompleteWeChatOAuthRegistrationAfterInvitationPendingSession(t *testing.T) {
|
||||||
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
|
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
|
||||||
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
|
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
|
||||||
@@ -322,6 +615,18 @@ func decodeCookieValueForTest(t *testing.T, value string) string {
|
|||||||
return string(raw)
|
return string(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertOAuthRedirectError(t *testing.T, location string, errorCode string, errorMessage string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
parsed, err := url.Parse(location)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fragment, err := url.ParseQuery(parsed.Fragment)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, errorCode, fragment.Get("error"))
|
||||||
|
require.Equal(t, errorMessage, fragment.Get("error_message"))
|
||||||
|
}
|
||||||
|
|
||||||
type wechatOAuthSettingRepoStub struct {
|
type wechatOAuthSettingRepoStub struct {
|
||||||
values map[string]string
|
values map[string]string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
@@ -41,7 +43,8 @@ type UpdateProfileRequest struct {
|
|||||||
|
|
||||||
type userProfileResponse struct {
|
type userProfileResponse struct {
|
||||||
dto.User
|
dto.User
|
||||||
AvatarURL string `json:"avatar_url,omitempty"`
|
AvatarURL string `json:"avatar_url,omitempty"`
|
||||||
|
Identities service.UserIdentitySummarySet `json:"identities"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProfile handles getting user profile
|
// GetProfile handles getting user profile
|
||||||
@@ -59,7 +62,13 @@ func (h *UserHandler) GetProfile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, userProfileResponseFromService(userData))
|
profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, userData)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, profileResp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChangePassword handles changing user password
|
// ChangePassword handles changing user password
|
||||||
@@ -117,7 +126,44 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, userProfileResponseFromService(updatedUser))
|
profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, profileResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
type StartIdentityBindingRequest struct {
|
||||||
|
Provider string `json:"provider" binding:"required"`
|
||||||
|
RedirectTo string `json:"redirect_to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartIdentityBinding returns the backend authorize URL for starting a third-party identity bind flow.
|
||||||
|
// POST /api/v1/user/auth-identities/bind/start
|
||||||
|
func (h *UserHandler) StartIdentityBinding(c *gin.Context) {
|
||||||
|
if _, ok := middleware2.GetAuthSubjectFromContext(c); !ok {
|
||||||
|
response.Unauthorized(c, "User not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req StartIdentityBindingRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.userService.PrepareIdentityBindingStart(c.Request.Context(), service.StartUserIdentityBindingRequest{
|
||||||
|
Provider: req.Provider,
|
||||||
|
RedirectTo: req.RedirectTo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendNotifyEmailCodeRequest represents the request to send notify email verification code
|
// SendNotifyEmailCodeRequest represents the request to send notify email verification code
|
||||||
@@ -183,7 +229,13 @@ func (h *UserHandler) VerifyNotifyEmail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, userProfileResponseFromService(updatedUser))
|
profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, profileResp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveNotifyEmailRequest represents the request to remove a notify email
|
// RemoveNotifyEmailRequest represents the request to remove a notify email
|
||||||
@@ -219,7 +271,13 @@ func (h *UserHandler) RemoveNotifyEmail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, userProfileResponseFromService(updatedUser))
|
profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, profileResp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToggleNotifyEmailRequest represents the request to toggle a notify email's disabled state
|
// ToggleNotifyEmailRequest represents the request to toggle a notify email's disabled state
|
||||||
@@ -255,16 +313,31 @@ func (h *UserHandler) ToggleNotifyEmail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, userProfileResponseFromService(updatedUser))
|
profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, profileResp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func userProfileResponseFromService(user *service.User) userProfileResponse {
|
func (h *UserHandler) buildUserProfileResponse(ctx context.Context, userID int64, user *service.User) (userProfileResponse, error) {
|
||||||
|
identities, err := h.userService.GetProfileIdentitySummaries(ctx, userID, user)
|
||||||
|
if err != nil {
|
||||||
|
return userProfileResponse{}, err
|
||||||
|
}
|
||||||
|
return userProfileResponseFromService(user, identities), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userProfileResponseFromService(user *service.User, identities service.UserIdentitySummarySet) userProfileResponse {
|
||||||
base := dto.UserFromService(user)
|
base := dto.UserFromService(user)
|
||||||
if base == nil {
|
if base == nil {
|
||||||
return userProfileResponse{}
|
return userProfileResponse{}
|
||||||
}
|
}
|
||||||
return userProfileResponse{
|
return userProfileResponse{
|
||||||
User: *base,
|
User: *base,
|
||||||
AvatarURL: user.AvatarURL,
|
AvatarURL: user.AvatarURL,
|
||||||
|
Identities: identities,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
@@ -18,7 +19,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type userHandlerRepoStub struct {
|
type userHandlerRepoStub struct {
|
||||||
user *service.User
|
user *service.User
|
||||||
|
identities []service.UserAuthIdentityRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userHandlerRepoStub) Create(context.Context, *service.User) error { return nil }
|
func (s *userHandlerRepoStub) Create(context.Context, *service.User) error { return nil }
|
||||||
@@ -96,6 +98,11 @@ func (s *userHandlerRepoStub) RemoveGroupFromUserAllowedGroups(context.Context,
|
|||||||
func (s *userHandlerRepoStub) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
|
func (s *userHandlerRepoStub) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
|
||||||
func (s *userHandlerRepoStub) EnableTotp(context.Context, int64) error { return nil }
|
func (s *userHandlerRepoStub) EnableTotp(context.Context, int64) error { return nil }
|
||||||
func (s *userHandlerRepoStub) DisableTotp(context.Context, int64) error { return nil }
|
func (s *userHandlerRepoStub) DisableTotp(context.Context, int64) error { return nil }
|
||||||
|
func (s *userHandlerRepoStub) ListUserAuthIdentities(context.Context, int64) ([]service.UserAuthIdentityRecord, error) {
|
||||||
|
out := make([]service.UserAuthIdentityRecord, len(s.identities))
|
||||||
|
copy(out, s.identities)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserHandlerUpdateProfileReturnsAvatarURL(t *testing.T) {
|
func TestUserHandlerUpdateProfileReturnsAvatarURL(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
@@ -134,3 +141,135 @@ func TestUserHandlerUpdateProfileReturnsAvatarURL(t *testing.T) {
|
|||||||
require.Equal(t, "https://cdn.example.com/avatar.png", resp.Data.AvatarURL)
|
require.Equal(t, "https://cdn.example.com/avatar.png", resp.Data.AvatarURL)
|
||||||
require.Equal(t, "handler-avatar", resp.Data.Username)
|
require.Equal(t, "handler-avatar", resp.Data.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserHandlerGetProfileReturnsIdentitySummaries(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
verifiedAt := time.Date(2026, 4, 20, 8, 30, 0, 0, time.UTC)
|
||||||
|
repo := &userHandlerRepoStub{
|
||||||
|
user: &service.User{
|
||||||
|
ID: 11,
|
||||||
|
Email: "identity@example.com",
|
||||||
|
Username: "identity-user",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
},
|
||||||
|
identities: []service.UserAuthIdentityRecord{
|
||||||
|
{
|
||||||
|
ProviderType: "linuxdo",
|
||||||
|
ProviderKey: "linuxdo",
|
||||||
|
ProviderSubject: "linuxdo-subject-123456",
|
||||||
|
VerifiedAt: &verifiedAt,
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"username": "linuxdo-handle",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ProviderType: "oidc",
|
||||||
|
ProviderKey: "https://issuer.example.com",
|
||||||
|
ProviderSubject: "oidc-user-abc",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"suggested_display_name": "OIDC Display",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil)
|
||||||
|
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 11})
|
||||||
|
|
||||||
|
handler.GetProfile(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
Identities struct {
|
||||||
|
Email struct {
|
||||||
|
Bound bool `json:"bound"`
|
||||||
|
BoundCount int `json:"bound_count"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
} `json:"email"`
|
||||||
|
LinuxDo struct {
|
||||||
|
Bound bool `json:"bound"`
|
||||||
|
BoundCount int `json:"bound_count"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
ProviderKey string `json:"provider_key"`
|
||||||
|
} `json:"linuxdo"`
|
||||||
|
OIDC struct {
|
||||||
|
Bound bool `json:"bound"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
ProviderKey string `json:"provider_key"`
|
||||||
|
} `json:"oidc"`
|
||||||
|
WeChat struct {
|
||||||
|
Bound bool `json:"bound"`
|
||||||
|
CanBind bool `json:"can_bind"`
|
||||||
|
BindStartPath string `json:"bind_start_path"`
|
||||||
|
} `json:"wechat"`
|
||||||
|
} `json:"identities"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
||||||
|
require.Equal(t, 0, resp.Code)
|
||||||
|
require.True(t, resp.Data.Identities.Email.Bound)
|
||||||
|
require.Equal(t, 1, resp.Data.Identities.Email.BoundCount)
|
||||||
|
require.Equal(t, "identity@example.com", resp.Data.Identities.Email.DisplayName)
|
||||||
|
require.True(t, resp.Data.Identities.LinuxDo.Bound)
|
||||||
|
require.Equal(t, 1, resp.Data.Identities.LinuxDo.BoundCount)
|
||||||
|
require.Equal(t, "linuxdo-handle", resp.Data.Identities.LinuxDo.DisplayName)
|
||||||
|
require.Equal(t, "linuxdo", resp.Data.Identities.LinuxDo.ProviderKey)
|
||||||
|
require.True(t, resp.Data.Identities.OIDC.Bound)
|
||||||
|
require.Equal(t, "OIDC Display", resp.Data.Identities.OIDC.DisplayName)
|
||||||
|
require.Equal(t, "https://issuer.example.com", resp.Data.Identities.OIDC.ProviderKey)
|
||||||
|
require.False(t, resp.Data.Identities.WeChat.Bound)
|
||||||
|
require.True(t, resp.Data.Identities.WeChat.CanBind)
|
||||||
|
require.Contains(t, resp.Data.Identities.WeChat.BindStartPath, "/api/v1/auth/oauth/wechat/start")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserHandlerStartIdentityBindingReturnsAuthorizeURL(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
repo := &userHandlerRepoStub{
|
||||||
|
user: &service.User{
|
||||||
|
ID: 11,
|
||||||
|
Email: "identity@example.com",
|
||||||
|
Username: "identity-user",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil)
|
||||||
|
|
||||||
|
body := []byte(`{"provider":"wechat","redirect_to":"/settings/profile"}`)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/user/auth-identities/bind/start", bytes.NewReader(body))
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 11})
|
||||||
|
|
||||||
|
handler.StartIdentityBinding(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
AuthorizeURL string `json:"authorize_url"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
UseBrowserRedirect bool `json:"use_browser_redirect"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
||||||
|
require.Equal(t, 0, resp.Code)
|
||||||
|
require.Equal(t, "wechat", resp.Data.Provider)
|
||||||
|
require.Equal(t, "GET", resp.Data.Method)
|
||||||
|
require.True(t, resp.Data.UseBrowserRedirect)
|
||||||
|
require.Contains(t, resp.Data.AuthorizeURL, "/api/v1/auth/oauth/wechat/start")
|
||||||
|
require.Contains(t, resp.Data.AuthorizeURL, "intent=bind_current_user")
|
||||||
|
require.Contains(t, resp.Data.AuthorizeURL, "redirect=%2Fsettings%2Fprofile")
|
||||||
|
}
|
||||||
|
|||||||
@@ -211,6 +211,34 @@ func (r *userRepository) GetUserByChannelIdentity(ctx context.Context, key AuthI
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) ListUserAuthIdentities(ctx context.Context, userID int64) ([]service.UserAuthIdentityRecord, error) {
|
||||||
|
identities, err := clientFromContext(ctx, r.client).AuthIdentity.Query().
|
||||||
|
Where(authidentity.UserIDEQ(userID)).
|
||||||
|
All(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
records := make([]service.UserAuthIdentityRecord, 0, len(identities))
|
||||||
|
for _, identity := range identities {
|
||||||
|
if identity == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, service.UserAuthIdentityRecord{
|
||||||
|
ProviderType: strings.TrimSpace(identity.ProviderType),
|
||||||
|
ProviderKey: strings.TrimSpace(identity.ProviderKey),
|
||||||
|
ProviderSubject: strings.TrimSpace(identity.ProviderSubject),
|
||||||
|
VerifiedAt: identity.VerifiedAt,
|
||||||
|
Issuer: identity.Issuer,
|
||||||
|
Metadata: copyMetadata(identity.Metadata),
|
||||||
|
CreatedAt: identity.CreatedAt,
|
||||||
|
UpdatedAt: identity.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *userRepository) BindAuthIdentityToUser(ctx context.Context, input BindAuthIdentityInput) (*CreateAuthIdentityResult, error) {
|
func (r *userRepository) BindAuthIdentityToUser(ctx context.Context, input BindAuthIdentityInput) (*CreateAuthIdentityResult, error) {
|
||||||
var result *CreateAuthIdentityResult
|
var result *CreateAuthIdentityResult
|
||||||
err := r.WithUserProfileIdentityTx(ctx, func(txCtx context.Context) error {
|
err := r.WithUserProfileIdentityTx(ctx, func(txCtx context.Context) error {
|
||||||
|
|||||||
@@ -108,5 +108,23 @@ func RegisterAuthRoutes(
|
|||||||
authenticated.GET("/auth/me", h.Auth.GetCurrentUser)
|
authenticated.GET("/auth/me", h.Auth.GetCurrentUser)
|
||||||
// 撤销所有会话(需要认证)
|
// 撤销所有会话(需要认证)
|
||||||
authenticated.POST("/auth/revoke-all-sessions", h.Auth.RevokeAllSessions)
|
authenticated.POST("/auth/revoke-all-sessions", h.Auth.RevokeAllSessions)
|
||||||
|
authenticated.GET("/auth/oauth/linuxdo/bind/start", func(c *gin.Context) {
|
||||||
|
query := c.Request.URL.Query()
|
||||||
|
query.Set("intent", "bind_current_user")
|
||||||
|
c.Request.URL.RawQuery = query.Encode()
|
||||||
|
h.Auth.LinuxDoOAuthStart(c)
|
||||||
|
})
|
||||||
|
authenticated.GET("/auth/oauth/oidc/bind/start", func(c *gin.Context) {
|
||||||
|
query := c.Request.URL.Query()
|
||||||
|
query.Set("intent", "bind_current_user")
|
||||||
|
c.Request.URL.RawQuery = query.Encode()
|
||||||
|
h.Auth.OIDCOAuthStart(c)
|
||||||
|
})
|
||||||
|
authenticated.GET("/auth/oauth/wechat/bind/start", func(c *gin.Context) {
|
||||||
|
query := c.Request.URL.Query()
|
||||||
|
query.Set("intent", "bind_current_user")
|
||||||
|
c.Request.URL.RawQuery = query.Encode()
|
||||||
|
h.Auth.WeChatOAuthStart(c)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ func RegisterUserRoutes(
|
|||||||
user.GET("/profile", h.User.GetProfile)
|
user.GET("/profile", h.User.GetProfile)
|
||||||
user.PUT("/password", h.User.ChangePassword)
|
user.PUT("/password", h.User.ChangePassword)
|
||||||
user.PUT("", h.User.UpdateProfile)
|
user.PUT("", h.User.UpdateProfile)
|
||||||
|
user.POST("/auth-identities/bind/start", h.User.StartIdentityBinding)
|
||||||
|
|
||||||
// 通知邮箱管理
|
// 通知邮箱管理
|
||||||
notifyEmail := user.Group("/notify-email")
|
notifyEmail := user.Group("/notify-email")
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -24,6 +24,8 @@ var (
|
|||||||
ErrAvatarInvalid = infraerrors.BadRequest("AVATAR_INVALID", "avatar must be a valid image data URL or http(s) URL")
|
ErrAvatarInvalid = infraerrors.BadRequest("AVATAR_INVALID", "avatar must be a valid image data URL or http(s) URL")
|
||||||
ErrAvatarTooLarge = infraerrors.BadRequest("AVATAR_TOO_LARGE", "avatar image must be 100KB or smaller")
|
ErrAvatarTooLarge = infraerrors.BadRequest("AVATAR_TOO_LARGE", "avatar image must be 100KB or smaller")
|
||||||
ErrAvatarNotImage = infraerrors.BadRequest("AVATAR_NOT_IMAGE", "avatar content must be an image")
|
ErrAvatarNotImage = infraerrors.BadRequest("AVATAR_NOT_IMAGE", "avatar content must be an image")
|
||||||
|
ErrIdentityProviderInvalid = infraerrors.BadRequest("IDENTITY_PROVIDER_INVALID", "identity provider is invalid")
|
||||||
|
ErrIdentityRedirectInvalid = infraerrors.BadRequest("IDENTITY_REDIRECT_INVALID", "identity redirect path is invalid")
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -33,6 +35,8 @@ const (
|
|||||||
// User-level rate limiting for notify email verification codes
|
// User-level rate limiting for notify email verification codes
|
||||||
notifyCodeUserRateLimit = 5
|
notifyCodeUserRateLimit = 5
|
||||||
notifyCodeUserRateWindow = 10 * time.Minute
|
notifyCodeUserRateWindow = 10 * time.Minute
|
||||||
|
|
||||||
|
defaultUserIdentityRedirect = "/settings/profile"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserListFilters contains all filter options for listing users
|
// UserListFilters contains all filter options for listing users
|
||||||
@@ -71,6 +75,7 @@ type UserRepository interface {
|
|||||||
AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error
|
AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error
|
||||||
// RemoveGroupFromUserAllowedGroups 移除单个用户的指定分组权限
|
// RemoveGroupFromUserAllowedGroups 移除单个用户的指定分组权限
|
||||||
RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error
|
RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error
|
||||||
|
ListUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error)
|
||||||
|
|
||||||
// TOTP 双因素认证
|
// TOTP 双因素认证
|
||||||
UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error
|
UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error
|
||||||
@@ -78,6 +83,50 @@ type UserRepository interface {
|
|||||||
DisableTotp(ctx context.Context, userID int64) error
|
DisableTotp(ctx context.Context, userID int64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserAuthIdentityRecord struct {
|
||||||
|
ProviderType string
|
||||||
|
ProviderKey string
|
||||||
|
ProviderSubject string
|
||||||
|
VerifiedAt *time.Time
|
||||||
|
Issuer *string
|
||||||
|
Metadata map[string]any
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserIdentitySummary struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Bound bool `json:"bound"`
|
||||||
|
BoundCount int `json:"bound_count"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
SubjectHint string `json:"subject_hint,omitempty"`
|
||||||
|
ProviderKey string `json:"provider_key,omitempty"`
|
||||||
|
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
||||||
|
BindStartPath string `json:"bind_start_path,omitempty"`
|
||||||
|
CanBind bool `json:"can_bind"`
|
||||||
|
CanUnbind bool `json:"can_unbind"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserIdentitySummarySet struct {
|
||||||
|
Email UserIdentitySummary `json:"email"`
|
||||||
|
LinuxDo UserIdentitySummary `json:"linuxdo"`
|
||||||
|
OIDC UserIdentitySummary `json:"oidc"`
|
||||||
|
WeChat UserIdentitySummary `json:"wechat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StartUserIdentityBindingRequest struct {
|
||||||
|
Provider string
|
||||||
|
RedirectTo string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StartUserIdentityBindingResult struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
AuthorizeURL string `json:"authorize_url"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
UseBrowserRedirect bool `json:"use_browser_redirect"`
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateProfileRequest 更新用户资料请求
|
// UpdateProfileRequest 更新用户资料请求
|
||||||
type UpdateProfileRequest struct {
|
type UpdateProfileRequest struct {
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
@@ -106,6 +155,10 @@ type UpsertUserAvatarInput struct {
|
|||||||
SHA256 string
|
SHA256 string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type userAuthIdentityReader interface {
|
||||||
|
ListUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error)
|
||||||
|
}
|
||||||
|
|
||||||
// ChangePasswordRequest 修改密码请求
|
// ChangePasswordRequest 修改密码请求
|
||||||
type ChangePasswordRequest struct {
|
type ChangePasswordRequest struct {
|
||||||
CurrentPassword string `json:"current_password"`
|
CurrentPassword string `json:"current_password"`
|
||||||
@@ -151,6 +204,47 @@ func (s *UserService) GetProfile(ctx context.Context, userID int64) (*User, erro
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UserService) GetProfileIdentitySummaries(ctx context.Context, userID int64, user *User) (UserIdentitySummarySet, error) {
|
||||||
|
if user == nil {
|
||||||
|
var err error
|
||||||
|
user, err = s.userRepo.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return UserIdentitySummarySet{}, fmt.Errorf("get user: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := s.listUserAuthIdentities(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return UserIdentitySummarySet{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserIdentitySummarySet{
|
||||||
|
Email: s.buildEmailIdentitySummary(user),
|
||||||
|
LinuxDo: s.buildProviderIdentitySummary("linuxdo", records),
|
||||||
|
OIDC: s.buildProviderIdentitySummary("oidc", records),
|
||||||
|
WeChat: s.buildProviderIdentitySummary("wechat", records),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) PrepareIdentityBindingStart(_ context.Context, req StartUserIdentityBindingRequest) (*StartUserIdentityBindingResult, error) {
|
||||||
|
provider := normalizeUserIdentityProvider(req.Provider)
|
||||||
|
if provider == "" {
|
||||||
|
return nil, ErrIdentityProviderInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
authorizeURL, err := buildUserIdentityBindAuthorizeURL(provider, req.RedirectTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &StartUserIdentityBindingResult{
|
||||||
|
Provider: provider,
|
||||||
|
AuthorizeURL: authorizeURL,
|
||||||
|
Method: "GET",
|
||||||
|
UseBrowserRedirect: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateProfile 更新用户资料
|
// UpdateProfile 更新用户资料
|
||||||
func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*User, error) {
|
func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*User, error) {
|
||||||
user, err := s.userRepo.GetByID(ctx, userID)
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||||||
@@ -303,6 +397,234 @@ func normalizeInlineUserAvatarInput(raw string) (UpsertUserAvatarInput, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UserService) buildEmailIdentitySummary(user *User) UserIdentitySummary {
|
||||||
|
summary := UserIdentitySummary{
|
||||||
|
Provider: "email",
|
||||||
|
CanBind: false,
|
||||||
|
CanUnbind: false,
|
||||||
|
Note: "Primary account email is managed from the profile form.",
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
email := strings.TrimSpace(user.Email)
|
||||||
|
if email == "" || isReservedEmail(email) {
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Bound = true
|
||||||
|
summary.BoundCount = 1
|
||||||
|
summary.DisplayName = email
|
||||||
|
summary.SubjectHint = maskEmailIdentity(email)
|
||||||
|
summary.ProviderKey = "email"
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) buildProviderIdentitySummary(provider string, records []UserAuthIdentityRecord) UserIdentitySummary {
|
||||||
|
summary := UserIdentitySummary{
|
||||||
|
Provider: provider,
|
||||||
|
CanUnbind: false,
|
||||||
|
}
|
||||||
|
filtered := filterUserAuthIdentities(records, provider)
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
summary.CanBind = true
|
||||||
|
bindStartPath, err := buildUserIdentityBindAuthorizeURL(provider, "")
|
||||||
|
if err == nil {
|
||||||
|
summary.BindStartPath = bindStartPath
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
primary := selectPrimaryUserAuthIdentity(filtered)
|
||||||
|
summary.Bound = true
|
||||||
|
summary.BoundCount = len(filtered)
|
||||||
|
summary.DisplayName = userAuthIdentityDisplayName(primary)
|
||||||
|
summary.SubjectHint = maskOpaqueIdentity(primary.ProviderSubject)
|
||||||
|
summary.ProviderKey = strings.TrimSpace(primary.ProviderKey)
|
||||||
|
summary.VerifiedAt = primary.VerifiedAt
|
||||||
|
summary.Note = "Unbind is not available yet."
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) listUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error) {
|
||||||
|
if userID <= 0 || s == nil || s.userRepo == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return s.userRepo.ListUserAuthIdentities(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUserIdentityBindAuthorizeURL(provider, redirectTo string) (string, error) {
|
||||||
|
provider = normalizeUserIdentityProvider(provider)
|
||||||
|
if provider == "" || provider == "email" {
|
||||||
|
return "", ErrIdentityProviderInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectTo, err := normalizeUserIdentityRedirect(redirectTo)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
path := ""
|
||||||
|
switch provider {
|
||||||
|
case "linuxdo":
|
||||||
|
path = "/api/v1/auth/oauth/linuxdo/start"
|
||||||
|
case "oidc":
|
||||||
|
path = "/api/v1/auth/oauth/oidc/start"
|
||||||
|
case "wechat":
|
||||||
|
path = "/api/v1/auth/oauth/wechat/start"
|
||||||
|
default:
|
||||||
|
return "", ErrIdentityProviderInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("redirect", redirectTo)
|
||||||
|
query.Set("intent", "bind_current_user")
|
||||||
|
return path + "?" + query.Encode(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeUserIdentityProvider(provider string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||||
|
case "linuxdo":
|
||||||
|
return "linuxdo"
|
||||||
|
case "oidc":
|
||||||
|
return "oidc"
|
||||||
|
case "wechat":
|
||||||
|
return "wechat"
|
||||||
|
case "email":
|
||||||
|
return "email"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeUserIdentityRedirect(raw string) (string, error) {
|
||||||
|
redirect := strings.TrimSpace(raw)
|
||||||
|
if redirect == "" {
|
||||||
|
return defaultUserIdentityRedirect, nil
|
||||||
|
}
|
||||||
|
if len(redirect) > 2048 || !strings.HasPrefix(redirect, "/") || strings.HasPrefix(redirect, "//") {
|
||||||
|
return "", ErrIdentityRedirectInvalid
|
||||||
|
}
|
||||||
|
return redirect, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterUserAuthIdentities(records []UserAuthIdentityRecord, provider string) []UserAuthIdentityRecord {
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
filtered := make([]UserAuthIdentityRecord, 0, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(record.ProviderType), provider) {
|
||||||
|
filtered = append(filtered, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectPrimaryUserAuthIdentity(records []UserAuthIdentityRecord) UserAuthIdentityRecord {
|
||||||
|
if len(records) == 0 {
|
||||||
|
return UserAuthIdentityRecord{}
|
||||||
|
}
|
||||||
|
sort.SliceStable(records, func(i, j int) bool {
|
||||||
|
left := userAuthIdentitySortTime(records[i])
|
||||||
|
right := userAuthIdentitySortTime(records[j])
|
||||||
|
if !left.Equal(right) {
|
||||||
|
return left.After(right)
|
||||||
|
}
|
||||||
|
return records[i].ProviderKey < records[j].ProviderKey
|
||||||
|
})
|
||||||
|
return records[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func userAuthIdentitySortTime(record UserAuthIdentityRecord) time.Time {
|
||||||
|
if record.VerifiedAt != nil && !record.VerifiedAt.IsZero() {
|
||||||
|
return record.VerifiedAt.UTC()
|
||||||
|
}
|
||||||
|
if !record.UpdatedAt.IsZero() {
|
||||||
|
return record.UpdatedAt.UTC()
|
||||||
|
}
|
||||||
|
if !record.CreatedAt.IsZero() {
|
||||||
|
return record.CreatedAt.UTC()
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func userAuthIdentityDisplayName(record UserAuthIdentityRecord) string {
|
||||||
|
if displayName := firstStringIdentityValue(record.Metadata,
|
||||||
|
"display_name",
|
||||||
|
"suggested_display_name",
|
||||||
|
"username",
|
||||||
|
"name",
|
||||||
|
"nickname",
|
||||||
|
"email",
|
||||||
|
); displayName != "" {
|
||||||
|
return displayName
|
||||||
|
}
|
||||||
|
if subject := strings.TrimSpace(record.ProviderSubject); subject != "" {
|
||||||
|
return subject
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(record.ProviderType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstStringIdentityValue(values map[string]any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
raw, ok := values[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case string:
|
||||||
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
case fmt.Stringer:
|
||||||
|
if trimmed := strings.TrimSpace(value.String()); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskEmailIdentity(email string) string {
|
||||||
|
local, domain, ok := strings.Cut(strings.TrimSpace(email), "@")
|
||||||
|
if !ok || local == "" || domain == "" {
|
||||||
|
return maskOpaqueIdentity(email)
|
||||||
|
}
|
||||||
|
runes := []rune(local)
|
||||||
|
if len(runes) == 1 {
|
||||||
|
return string(runes[0]) + "***@" + domain
|
||||||
|
}
|
||||||
|
return string(runes[0]) + "***" + string(runes[len(runes)-1]) + "@" + domain
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskOpaqueIdentity(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
runes := []rune(value)
|
||||||
|
switch {
|
||||||
|
case len(runes) == 0:
|
||||||
|
return ""
|
||||||
|
case len(runes) <= 4:
|
||||||
|
return string(runes[0]) + "***"
|
||||||
|
case len(runes) <= 8:
|
||||||
|
return string(runes[:2]) + "***" + string(runes[len(runes)-1:])
|
||||||
|
default:
|
||||||
|
return string(runes[:3]) + "***" + string(runes[len(runes)-3:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneAnyMap(values map[string]any) map[string]any {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
cloned := make(map[string]any, len(values))
|
||||||
|
for key, value := range values {
|
||||||
|
cloned[key] = value
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
// ChangePassword 修改密码
|
// ChangePassword 修改密码
|
||||||
// Security: Increments TokenVersion to invalidate all existing JWT tokens
|
// Security: Increments TokenVersion to invalidate all existing JWT tokens
|
||||||
func (s *UserService) ChangePassword(ctx context.Context, userID int64, req ChangePasswordRequest) error {
|
func (s *UserService) ChangePassword(ctx context.Context, userID int64, req ChangePasswordRequest) error {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ describe('oauth adoption auth api', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
post.mockReset()
|
post.mockReset()
|
||||||
post.mockResolvedValue({ data: {} })
|
post.mockResolvedValue({ data: {} })
|
||||||
|
localStorage.clear()
|
||||||
|
document.cookie = 'oauth_bind_access_token=; Max-Age=0; path=/'
|
||||||
})
|
})
|
||||||
|
|
||||||
it('posts adoption decisions when exchanging pending oauth completion', async () => {
|
it('posts adoption decisions when exchanging pending oauth completion', async () => {
|
||||||
@@ -57,4 +59,43 @@ describe('oauth adoption auth api', () => {
|
|||||||
adopt_avatar: true
|
adopt_avatar: true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('posts wechat invitation completion with adoption decisions', async () => {
|
||||||
|
const { completeWeChatOAuthRegistration } = await import('@/api/auth')
|
||||||
|
|
||||||
|
await completeWeChatOAuthRegistration('invite-code', {
|
||||||
|
adoptDisplayName: true,
|
||||||
|
adoptAvatar: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', {
|
||||||
|
invitation_code: 'invite-code',
|
||||||
|
adopt_display_name: true,
|
||||||
|
adopt_avatar: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies oauth completion results as login or bind', async () => {
|
||||||
|
const { getOAuthCompletionKind } = await import('@/api/auth')
|
||||||
|
|
||||||
|
expect(getOAuthCompletionKind({ access_token: 'access-token' })).toBe('login')
|
||||||
|
expect(getOAuthCompletionKind({ redirect: '/profile' })).toBe('bind')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prepares an oauth bind access token cookie before redirect binding', async () => {
|
||||||
|
localStorage.setItem('auth_token', 'access-token-value')
|
||||||
|
const setCookie = vi.fn()
|
||||||
|
Object.defineProperty(document, 'cookie', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => '',
|
||||||
|
set: setCookie
|
||||||
|
})
|
||||||
|
|
||||||
|
const { prepareOAuthBindAccessTokenCookie } = await import('@/api/auth')
|
||||||
|
|
||||||
|
prepareOAuthBindAccessTokenCookie()
|
||||||
|
|
||||||
|
expect(setCookie).toHaveBeenCalledTimes(1)
|
||||||
|
expect(setCookie.mock.calls[0]?.[0]).toContain('oauth_bind_access_token=access-token-value')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -186,11 +186,14 @@ export interface RefreshTokenResponse {
|
|||||||
token_type: string
|
token_type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingOAuthExchangeResponse {
|
export interface OAuthTokenResponse {
|
||||||
access_token?: string
|
access_token: string
|
||||||
refresh_token?: string
|
refresh_token?: string
|
||||||
expires_in?: number
|
expires_in?: number
|
||||||
token_type?: string
|
token_type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingOAuthExchangeResponse extends Partial<OAuthTokenResponse> {
|
||||||
redirect?: string
|
redirect?: string
|
||||||
error?: string
|
error?: string
|
||||||
adoption_required?: boolean
|
adoption_required?: boolean
|
||||||
@@ -198,6 +201,8 @@ export interface PendingOAuthExchangeResponse {
|
|||||||
suggested_avatar_url?: string
|
suggested_avatar_url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OAuthCompletionKind = 'login' | 'bind'
|
||||||
|
|
||||||
export interface OAuthAdoptionDecision {
|
export interface OAuthAdoptionDecision {
|
||||||
adoptDisplayName?: boolean
|
adoptDisplayName?: boolean
|
||||||
adoptAvatar?: boolean
|
adoptAvatar?: boolean
|
||||||
@@ -218,6 +223,56 @@ function serializeOAuthAdoptionDecision(
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isOAuthLoginCompletion(
|
||||||
|
completion: Partial<OAuthTokenResponse>
|
||||||
|
): completion is OAuthTokenResponse {
|
||||||
|
return typeof completion.access_token === 'string' && completion.access_token.trim().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOAuthCompletionKind(
|
||||||
|
completion: Partial<OAuthTokenResponse>
|
||||||
|
): OAuthCompletionKind {
|
||||||
|
return isOAuthLoginCompletion(completion) ? 'login' : 'bind'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): void {
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
setRefreshToken(tokens.refresh_token)
|
||||||
|
}
|
||||||
|
if (tokens.expires_in) {
|
||||||
|
setTokenExpiresAt(tokens.expires_in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareOAuthBindAccessTokenCookie(): void {
|
||||||
|
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken()
|
||||||
|
if (!token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
|
||||||
|
const path = resolveOAuthBindCookiePath()
|
||||||
|
document.cookie =
|
||||||
|
`oauth_bind_access_token=${encodeURIComponent(token)}; Path=${path}/auth/oauth; Max-Age=600; SameSite=Lax${secure}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOAuthBindCookiePath(): string {
|
||||||
|
const apiBase = ((import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1').replace(/\/$/, '')
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(apiBase, window.location.origin).pathname.replace(/\/$/, '') || '/api/v1'
|
||||||
|
} catch {
|
||||||
|
if (apiBase.startsWith('/')) {
|
||||||
|
return apiBase
|
||||||
|
}
|
||||||
|
return '/api/v1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh the access token using the refresh token
|
* Refresh the access token using the refresh token
|
||||||
* @returns New token pair
|
* @returns New token pair
|
||||||
@@ -375,13 +430,8 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
|
|||||||
export async function completeLinuxDoOAuthRegistration(
|
export async function completeLinuxDoOAuthRegistration(
|
||||||
invitationCode: string,
|
invitationCode: string,
|
||||||
decision?: OAuthAdoptionDecision
|
decision?: OAuthAdoptionDecision
|
||||||
): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> {
|
): Promise<OAuthTokenResponse> {
|
||||||
const { data } = await apiClient.post<{
|
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/linuxdo/complete-registration', {
|
||||||
access_token: string
|
|
||||||
refresh_token: string
|
|
||||||
expires_in: number
|
|
||||||
token_type: string
|
|
||||||
}>('/auth/oauth/linuxdo/complete-registration', {
|
|
||||||
invitation_code: invitationCode,
|
invitation_code: invitationCode,
|
||||||
...serializeOAuthAdoptionDecision(decision)
|
...serializeOAuthAdoptionDecision(decision)
|
||||||
})
|
})
|
||||||
@@ -396,13 +446,19 @@ export async function completeLinuxDoOAuthRegistration(
|
|||||||
export async function completeOIDCOAuthRegistration(
|
export async function completeOIDCOAuthRegistration(
|
||||||
invitationCode: string,
|
invitationCode: string,
|
||||||
decision?: OAuthAdoptionDecision
|
decision?: OAuthAdoptionDecision
|
||||||
): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> {
|
): Promise<OAuthTokenResponse> {
|
||||||
const { data } = await apiClient.post<{
|
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/oidc/complete-registration', {
|
||||||
access_token: string
|
invitation_code: invitationCode,
|
||||||
refresh_token: string
|
...serializeOAuthAdoptionDecision(decision)
|
||||||
expires_in: number
|
})
|
||||||
token_type: string
|
return data
|
||||||
}>('/auth/oauth/oidc/complete-registration', {
|
}
|
||||||
|
|
||||||
|
export async function completeWeChatOAuthRegistration(
|
||||||
|
invitationCode: string,
|
||||||
|
decision?: OAuthAdoptionDecision
|
||||||
|
): Promise<OAuthTokenResponse> {
|
||||||
|
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/wechat/complete-registration', {
|
||||||
invitation_code: invitationCode,
|
invitation_code: invitationCode,
|
||||||
...serializeOAuthAdoptionDecision(decision)
|
...serializeOAuthAdoptionDecision(decision)
|
||||||
})
|
})
|
||||||
@@ -444,7 +500,8 @@ export const authAPI = {
|
|||||||
revokeAllSessions,
|
revokeAllSessions,
|
||||||
exchangePendingOAuthCompletion,
|
exchangePendingOAuthCompletion,
|
||||||
completeLinuxDoOAuthRegistration,
|
completeLinuxDoOAuthRegistration,
|
||||||
completeOIDCOAuthRegistration
|
completeOIDCOAuthRegistration,
|
||||||
|
completeWeChatOAuthRegistration
|
||||||
}
|
}
|
||||||
|
|
||||||
export default authAPI
|
export default authAPI
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
import type { User, ChangePasswordRequest, NotifyEmailEntry } from '@/types'
|
import { prepareOAuthBindAccessTokenCookie } from './auth'
|
||||||
|
import type { User, ChangePasswordRequest, NotifyEmailEntry, UserAuthProvider } from '@/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current user profile
|
* Get current user profile
|
||||||
@@ -83,6 +84,49 @@ export async function toggleNotifyEmail(email: string, disabled: boolean): Promi
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BindableOAuthProvider = Exclude<UserAuthProvider, 'email'>
|
||||||
|
|
||||||
|
interface BuildOAuthBindingStartURLOptions {
|
||||||
|
redirectTo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWeChatOAuthMode(): 'open' | 'mp' {
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
return 'open'
|
||||||
|
}
|
||||||
|
return /MicroMessenger/i.test(navigator.userAgent) ? 'mp' : 'open'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOAuthBindingStartURL(
|
||||||
|
provider: BindableOAuthProvider,
|
||||||
|
options: BuildOAuthBindingStartURLOptions = {}
|
||||||
|
): string {
|
||||||
|
const redirectTo = options.redirectTo?.trim() || '/profile'
|
||||||
|
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||||
|
const normalized = apiBase.replace(/\/$/, '')
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
redirect: redirectTo,
|
||||||
|
intent: 'bind_current_user'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (provider === 'wechat') {
|
||||||
|
params.set('mode', resolveWeChatOAuthMode())
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOAuthBinding(
|
||||||
|
provider: BindableOAuthProvider,
|
||||||
|
options: BuildOAuthBindingStartURLOptions = {}
|
||||||
|
): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prepareOAuthBindAccessTokenCookie()
|
||||||
|
window.location.href = buildOAuthBindingStartURL(provider, options)
|
||||||
|
}
|
||||||
|
|
||||||
export const userAPI = {
|
export const userAPI = {
|
||||||
getProfile,
|
getProfile,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
@@ -90,7 +134,9 @@ export const userAPI = {
|
|||||||
sendNotifyEmailCode,
|
sendNotifyEmailCode,
|
||||||
verifyNotifyEmail,
|
verifyNotifyEmail,
|
||||||
removeNotifyEmail,
|
removeNotifyEmail,
|
||||||
toggleNotifyEmail
|
toggleNotifyEmail,
|
||||||
|
buildOAuthBindingStartURL,
|
||||||
|
startOAuthBinding
|
||||||
}
|
}
|
||||||
|
|
||||||
export default userAPI
|
export default userAPI
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rounded-2xl border border-gray-100 bg-gray-50/80 p-4 dark:border-dark-700 dark:bg-dark-900/30">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('profile.authBindings.title') }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('profile.authBindings.description') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="item in providerItems"
|
||||||
|
:key="item.provider"
|
||||||
|
class="flex items-center justify-between gap-3 rounded-xl bg-white/80 px-3 py-2.5 dark:bg-dark-800/70"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ item.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
:data-testid="`profile-binding-${item.provider}-status`"
|
||||||
|
:class="['badge', item.bound ? 'badge-success' : 'badge-gray']"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
item.bound
|
||||||
|
? t('profile.authBindings.status.bound')
|
||||||
|
: t('profile.authBindings.status.notBound')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="item.canBind"
|
||||||
|
:data-testid="`profile-binding-${item.provider}-action`"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
@click="startBinding(item.provider)"
|
||||||
|
>
|
||||||
|
{{ t('profile.authBindings.bindAction', { providerName: item.label }) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { startOAuthBinding } from '@/api/user'
|
||||||
|
import type { User, UserAuthBindingStatus, UserAuthProvider } from '@/types'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
user: User | null
|
||||||
|
linuxdoEnabled?: boolean
|
||||||
|
oidcEnabled?: boolean
|
||||||
|
oidcProviderName?: string
|
||||||
|
wechatEnabled?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
linuxdoEnabled: false,
|
||||||
|
oidcEnabled: false,
|
||||||
|
oidcProviderName: 'OIDC',
|
||||||
|
wechatEnabled: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
function normalizeBindingStatus(binding: boolean | UserAuthBindingStatus | undefined): boolean | null {
|
||||||
|
if (typeof binding === 'boolean') {
|
||||||
|
return binding
|
||||||
|
}
|
||||||
|
if (!binding) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof binding.bound === 'boolean') {
|
||||||
|
return binding.bound
|
||||||
|
}
|
||||||
|
return Boolean(binding.provider_subject || binding.issuer || binding.provider_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBindingStatus(provider: UserAuthProvider): boolean {
|
||||||
|
const currentUser = props.user
|
||||||
|
|
||||||
|
if (provider === 'email') {
|
||||||
|
return typeof currentUser?.email_bound === 'boolean'
|
||||||
|
? currentUser.email_bound
|
||||||
|
: Boolean(currentUser?.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
const directFlag = currentUser?.[`${provider}_bound` as keyof User]
|
||||||
|
if (typeof directFlag === 'boolean') {
|
||||||
|
return directFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = currentUser?.auth_bindings?.[provider] ?? currentUser?.identity_bindings?.[provider]
|
||||||
|
const normalized = normalizeBindingStatus(nested)
|
||||||
|
return normalized ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerItems = computed(() => [
|
||||||
|
{
|
||||||
|
provider: 'email' as const,
|
||||||
|
label: t('profile.authBindings.providers.email'),
|
||||||
|
bound: getBindingStatus('email'),
|
||||||
|
canBind: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'linuxdo' as const,
|
||||||
|
label: t('profile.authBindings.providers.linuxdo'),
|
||||||
|
bound: getBindingStatus('linuxdo'),
|
||||||
|
canBind: props.linuxdoEnabled && !getBindingStatus('linuxdo'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'oidc' as const,
|
||||||
|
label: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
|
||||||
|
bound: getBindingStatus('oidc'),
|
||||||
|
canBind: props.oidcEnabled && !getBindingStatus('oidc'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'wechat' as const,
|
||||||
|
label: t('profile.authBindings.providers.wechat'),
|
||||||
|
bound: getBindingStatus('wechat'),
|
||||||
|
canBind: props.wechatEnabled && !getBindingStatus('wechat'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
function startBinding(provider: UserAuthProvider): void {
|
||||||
|
if (provider === 'email') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startOAuthBinding(provider, {
|
||||||
|
redirectTo: route.fullPath || '/profile',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -4,11 +4,16 @@
|
|||||||
class="border-b border-gray-100 bg-gradient-to-r from-primary-500/10 to-primary-600/5 px-6 py-5 dark:border-dark-700 dark:from-primary-500/20 dark:to-primary-600/10"
|
class="border-b border-gray-100 bg-gradient-to-r from-primary-500/10 to-primary-600/5 px-6 py-5 dark:border-dark-700 dark:from-primary-500/20 dark:to-primary-600/10"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<!-- Avatar -->
|
|
||||||
<div
|
<div
|
||||||
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 text-2xl font-bold text-white shadow-lg shadow-primary-500/20"
|
class="flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 text-2xl font-bold text-white shadow-lg shadow-primary-500/20"
|
||||||
>
|
>
|
||||||
{{ user?.email?.charAt(0).toUpperCase() || 'U' }}
|
<img
|
||||||
|
v-if="avatarUrl"
|
||||||
|
:src="avatarUrl"
|
||||||
|
:alt="displayName"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
>
|
||||||
|
<span v-else>{{ avatarInitial }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<h2 class="truncate text-lg font-semibold text-gray-900 dark:text-white">
|
<h2 class="truncate text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
@@ -41,18 +46,163 @@
|
|||||||
<span class="truncate">{{ user.username }}</span>
|
<span class="truncate">{{ user.username }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="sourceHints.length"
|
||||||
|
class="mt-4 grid gap-2 rounded-2xl border border-gray-100 bg-gray-50/80 p-3 text-xs text-gray-500 dark:border-dark-700 dark:bg-dark-900/30 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="hint in sourceHints"
|
||||||
|
:key="hint.key"
|
||||||
|
class="flex items-start gap-2"
|
||||||
|
>
|
||||||
|
<Icon name="link" size="sm" class="mt-0.5 text-gray-400 dark:text-gray-500" />
|
||||||
|
<span>{{ hint.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProfileIdentityBindingsSection
|
||||||
|
class="mt-4"
|
||||||
|
:user="user"
|
||||||
|
:linuxdo-enabled="linuxdoEnabled"
|
||||||
|
:oidc-enabled="oidcEnabled"
|
||||||
|
:oidc-provider-name="oidcProviderName"
|
||||||
|
:wechat-enabled="wechatEnabled"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import type { User } from '@/types'
|
import ProfileIdentityBindingsSection from '@/components/user/profile/ProfileIdentityBindingsSection.vue'
|
||||||
|
import type { User, UserAuthProvider, UserProfileSourceContext } from '@/types'
|
||||||
|
|
||||||
defineProps<{
|
const props = withDefaults(
|
||||||
user: User | null
|
defineProps<{
|
||||||
}>()
|
user: User | null
|
||||||
|
linuxdoEnabled?: boolean
|
||||||
|
oidcEnabled?: boolean
|
||||||
|
oidcProviderName?: string
|
||||||
|
wechatEnabled?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
linuxdoEnabled: false,
|
||||||
|
oidcEnabled: false,
|
||||||
|
oidcProviderName: 'OIDC',
|
||||||
|
wechatEnabled: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({
|
||||||
|
email: t('profile.authBindings.providers.email'),
|
||||||
|
linuxdo: t('profile.authBindings.providers.linuxdo'),
|
||||||
|
oidc: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
|
||||||
|
wechat: t('profile.authBindings.providers.wechat'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const avatarUrl = computed(() => props.user?.avatar_url?.trim() || '')
|
||||||
|
const displayName = computed(() => props.user?.username?.trim() || props.user?.email?.trim() || 'User')
|
||||||
|
const avatarInitial = computed(() => displayName.value.charAt(0).toUpperCase() || 'U')
|
||||||
|
|
||||||
|
function normalizeProvider(value: string): UserAuthProvider | null {
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
if (normalized === 'email' || normalized === 'linuxdo' || normalized === 'wechat') {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
if (normalized === 'oidc' || normalized.startsWith('oidc:') || normalized.startsWith('oidc/')) {
|
||||||
|
return 'oidc'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readObjectString(source: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = source[key]
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
return value.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveThirdPartySource(
|
||||||
|
rawSource: string | UserProfileSourceContext | null | undefined
|
||||||
|
): { provider: UserAuthProvider; label: string } | null {
|
||||||
|
if (!rawSource) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawSource === 'string') {
|
||||||
|
const provider = normalizeProvider(rawSource)
|
||||||
|
if (!provider || provider === 'email') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
label: providerLabels.value[provider],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceRecord = rawSource as Record<string, unknown>
|
||||||
|
const provider = normalizeProvider(
|
||||||
|
readObjectString(sourceRecord, 'provider', 'source', 'provider_type', 'auth_provider')
|
||||||
|
)
|
||||||
|
if (!provider || provider === 'email') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicitLabel = readObjectString(
|
||||||
|
sourceRecord,
|
||||||
|
'provider_label',
|
||||||
|
'label',
|
||||||
|
'provider_name',
|
||||||
|
'providerName'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
label: explicitLabel || providerLabels.value[provider],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceHints = computed(() => {
|
||||||
|
const currentUser = props.user
|
||||||
|
if (!currentUser) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const hints: Array<{ key: string; text: string }> = []
|
||||||
|
const avatarSource = resolveThirdPartySource(
|
||||||
|
currentUser.profile_sources?.avatar ?? currentUser.avatar_source
|
||||||
|
)
|
||||||
|
const usernameSource = resolveThirdPartySource(
|
||||||
|
currentUser.profile_sources?.username ??
|
||||||
|
currentUser.profile_sources?.display_name ??
|
||||||
|
currentUser.profile_sources?.nickname ??
|
||||||
|
currentUser.display_name_source ??
|
||||||
|
currentUser.username_source ??
|
||||||
|
currentUser.nickname_source
|
||||||
|
)
|
||||||
|
|
||||||
|
if (avatarSource) {
|
||||||
|
hints.push({
|
||||||
|
key: 'avatar',
|
||||||
|
text: t('profile.authBindings.source.avatar', { providerName: avatarSource.label }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usernameSource) {
|
||||||
|
hints.push({
|
||||||
|
key: 'username',
|
||||||
|
text: t('profile.authBindings.source.username', { providerName: usernameSource.label }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return hints
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import ProfileIdentityBindingsSection from '@/components/user/profile/ProfileIdentityBindingsSection.vue'
|
||||||
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
const routeState = vi.hoisted(() => ({
|
||||||
|
fullPath: '/profile',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const locationState = vi.hoisted(() => ({
|
||||||
|
current: { href: 'http://localhost/profile' } as { href: string },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRoute: () => routeState,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string, params?: Record<string, string>) => {
|
||||||
|
if (key === 'profile.authBindings.title') return 'Connected sign-in methods'
|
||||||
|
if (key === 'profile.authBindings.description') return 'Manage bound providers'
|
||||||
|
if (key === 'profile.authBindings.status.bound') return 'Bound'
|
||||||
|
if (key === 'profile.authBindings.status.notBound') return 'Not bound'
|
||||||
|
if (key === 'profile.authBindings.providers.email') return 'Email'
|
||||||
|
if (key === 'profile.authBindings.providers.linuxdo') return 'LinuxDo'
|
||||||
|
if (key === 'profile.authBindings.providers.wechat') return 'WeChat'
|
||||||
|
if (key === 'profile.authBindings.providers.oidc') return params?.providerName || 'OIDC'
|
||||||
|
if (key === 'profile.authBindings.bindAction') return `Bind ${params?.providerName || ''}`.trim()
|
||||||
|
return key
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function createUser(overrides: Partial<User> = {}): User {
|
||||||
|
return {
|
||||||
|
id: 7,
|
||||||
|
username: 'alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
role: 'user',
|
||||||
|
balance: 10,
|
||||||
|
concurrency: 2,
|
||||||
|
status: 'active',
|
||||||
|
allowed_groups: null,
|
||||||
|
balance_notify_enabled: true,
|
||||||
|
balance_notify_threshold: null,
|
||||||
|
balance_notify_extra_emails: [],
|
||||||
|
created_at: '2026-04-20T00:00:00Z',
|
||||||
|
updated_at: '2026-04-20T00:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProfileIdentityBindingsSection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
routeState.fullPath = '/profile'
|
||||||
|
locationState.current = { href: 'http://localhost/profile' }
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
value: locationState.current,
|
||||||
|
})
|
||||||
|
Object.defineProperty(window.navigator, 'userAgent', {
|
||||||
|
configurable: true,
|
||||||
|
value: 'Mozilla/5.0',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders provider binding states and provider-specific bind actions', () => {
|
||||||
|
const wrapper = mount(ProfileIdentityBindingsSection, {
|
||||||
|
props: {
|
||||||
|
user: createUser({
|
||||||
|
auth_bindings: {
|
||||||
|
email: { bound: true },
|
||||||
|
linuxdo: { bound: true },
|
||||||
|
oidc: { bound: false },
|
||||||
|
wechat: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
linuxdoEnabled: true,
|
||||||
|
oidcEnabled: true,
|
||||||
|
oidcProviderName: 'ExampleID',
|
||||||
|
wechatEnabled: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Bound')
|
||||||
|
expect(wrapper.get('[data-testid="profile-binding-linuxdo-status"]').text()).toBe('Bound')
|
||||||
|
expect(wrapper.get('[data-testid="profile-binding-oidc-status"]').text()).toBe('Not bound')
|
||||||
|
expect(wrapper.get('[data-testid="profile-binding-oidc-action"]').text()).toBe(
|
||||||
|
'Bind ExampleID'
|
||||||
|
)
|
||||||
|
expect(wrapper.get('[data-testid="profile-binding-wechat-action"]').text()).toBe('Bind WeChat')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts the WeChat bind flow for the current profile page', async () => {
|
||||||
|
const wrapper = mount(ProfileIdentityBindingsSection, {
|
||||||
|
props: {
|
||||||
|
user: createUser(),
|
||||||
|
linuxdoEnabled: false,
|
||||||
|
oidcEnabled: false,
|
||||||
|
wechatEnabled: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="profile-binding-wechat-action"]').trigger('click')
|
||||||
|
|
||||||
|
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/start?')
|
||||||
|
expect(locationState.current.href).toContain('mode=open')
|
||||||
|
expect(locationState.current.href).toContain('intent=bind_current_user')
|
||||||
|
expect(locationState.current.href).toContain('redirect=%2Fprofile')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -940,6 +940,26 @@ export default {
|
|||||||
maxEmailsReached: 'Maximum number of notification emails reached',
|
maxEmailsReached: 'Maximum number of notification emails reached',
|
||||||
unverified: 'Unverified',
|
unverified: 'Unverified',
|
||||||
verified: 'Verified',
|
verified: 'Verified',
|
||||||
|
},
|
||||||
|
authBindings: {
|
||||||
|
title: 'Connected Sign-In Methods',
|
||||||
|
description: 'View current bindings and connect another provider to this account.',
|
||||||
|
bindAction: 'Bind {providerName}',
|
||||||
|
bindSuccess: 'Account linked successfully',
|
||||||
|
status: {
|
||||||
|
bound: 'Bound',
|
||||||
|
notBound: 'Not bound',
|
||||||
|
},
|
||||||
|
providers: {
|
||||||
|
email: 'Email',
|
||||||
|
linuxdo: 'LinuxDo',
|
||||||
|
oidc: '{providerName}',
|
||||||
|
wechat: 'WeChat',
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
avatar: 'Avatar is currently synced from {providerName}',
|
||||||
|
username: 'Nickname is currently synced from {providerName}',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -944,6 +944,26 @@ export default {
|
|||||||
maxEmailsReached: '已达到通知邮箱数量上限',
|
maxEmailsReached: '已达到通知邮箱数量上限',
|
||||||
unverified: '未验证',
|
unverified: '未验证',
|
||||||
verified: '已验证',
|
verified: '已验证',
|
||||||
|
},
|
||||||
|
authBindings: {
|
||||||
|
title: '登录方式绑定',
|
||||||
|
description: '查看当前绑定状态,并将更多第三方登录方式关联到这个账号。',
|
||||||
|
bindAction: '绑定 {providerName}',
|
||||||
|
bindSuccess: '账号绑定成功',
|
||||||
|
status: {
|
||||||
|
bound: '已绑定',
|
||||||
|
notBound: '未绑定',
|
||||||
|
},
|
||||||
|
providers: {
|
||||||
|
email: '邮箱',
|
||||||
|
linuxdo: 'LinuxDo',
|
||||||
|
oidc: '{providerName}',
|
||||||
|
wechat: '微信',
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
avatar: '头像当前来自 {providerName}',
|
||||||
|
username: '昵称当前来自 {providerName}',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,47 @@ export interface NotifyEmailEntry {
|
|||||||
|
|
||||||
// ==================== User & Auth Types ====================
|
// ==================== User & Auth Types ====================
|
||||||
|
|
||||||
|
export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat'
|
||||||
|
|
||||||
|
export interface UserAuthBindingStatus {
|
||||||
|
bound?: boolean
|
||||||
|
provider?: UserAuthProvider | string
|
||||||
|
provider_key?: string | null
|
||||||
|
provider_subject?: string | null
|
||||||
|
issuer?: string | null
|
||||||
|
label?: string | null
|
||||||
|
provider_label?: string | null
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfileSourceContext {
|
||||||
|
provider?: UserAuthProvider | string
|
||||||
|
source?: string | null
|
||||||
|
label?: string | null
|
||||||
|
provider_label?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
|
avatar_url?: string | null
|
||||||
|
avatar_source?: string | UserProfileSourceContext | null
|
||||||
|
username_source?: string | UserProfileSourceContext | null
|
||||||
|
display_name_source?: string | UserProfileSourceContext | null
|
||||||
|
nickname_source?: string | UserProfileSourceContext | null
|
||||||
|
profile_sources?: {
|
||||||
|
avatar?: string | UserProfileSourceContext | null
|
||||||
|
username?: string | UserProfileSourceContext | null
|
||||||
|
display_name?: string | UserProfileSourceContext | null
|
||||||
|
nickname?: string | UserProfileSourceContext | null
|
||||||
|
}
|
||||||
|
auth_bindings?: Partial<Record<UserAuthProvider, boolean | UserAuthBindingStatus>>
|
||||||
|
identity_bindings?: Partial<Record<UserAuthProvider, boolean | UserAuthBindingStatus>>
|
||||||
|
email_bound?: boolean
|
||||||
|
linuxdo_bound?: boolean
|
||||||
|
oidc_bound?: boolean
|
||||||
|
wechat_bound?: boolean
|
||||||
role: 'admin' | 'user' // User role for authorization
|
role: 'admin' | 'user' // User role for authorization
|
||||||
balance: number // User balance for API usage
|
balance: number // User balance for API usage
|
||||||
concurrency: number // Allowed concurrent requests
|
concurrency: number // Allowed concurrent requests
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ import { useAuthStore, useAppStore } from '@/stores'
|
|||||||
import {
|
import {
|
||||||
completeLinuxDoOAuthRegistration,
|
completeLinuxDoOAuthRegistration,
|
||||||
exchangePendingOAuthCompletion,
|
exchangePendingOAuthCompletion,
|
||||||
|
getOAuthCompletionKind,
|
||||||
|
isOAuthLoginCompletion,
|
||||||
|
persistOAuthTokenContext,
|
||||||
type OAuthAdoptionDecision,
|
type OAuthAdoptionDecision,
|
||||||
type PendingOAuthExchangeResponse
|
type PendingOAuthExchangeResponse
|
||||||
} from '@/api/auth'
|
} from '@/api/auth'
|
||||||
@@ -162,6 +165,7 @@ const suggestedAvatarUrl = ref('')
|
|||||||
const adoptDisplayName = ref(true)
|
const adoptDisplayName = ref(true)
|
||||||
const adoptAvatar = ref(true)
|
const adoptAvatar = ref(true)
|
||||||
const needsAdoptionConfirmation = ref(false)
|
const needsAdoptionConfirmation = ref(false)
|
||||||
|
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
||||||
|
|
||||||
function parseFragmentParams(): URLSearchParams {
|
function parseFragmentParams(): URLSearchParams {
|
||||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||||
@@ -209,18 +213,19 @@ function hasSuggestedProfile(completion: {
|
|||||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function finalizeLogin(completion: PendingOAuthExchangeResponse, redirect: string) {
|
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||||
if (!completion.access_token) {
|
if (getOAuthCompletionKind(completion) === 'bind') {
|
||||||
|
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
||||||
|
appStore.showSuccess(bindSuccessMessage)
|
||||||
|
await router.replace(bindRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOAuthLoginCompletion(completion)) {
|
||||||
throw new Error(t('auth.linuxdo.callbackMissingToken'))
|
throw new Error(t('auth.linuxdo.callbackMissingToken'))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completion.refresh_token) {
|
persistOAuthTokenContext(completion)
|
||||||
localStorage.setItem('refresh_token', completion.refresh_token)
|
|
||||||
}
|
|
||||||
if (completion.expires_in) {
|
|
||||||
localStorage.setItem('token_expires_at', String(Date.now() + completion.expires_in * 1000))
|
|
||||||
}
|
|
||||||
|
|
||||||
await authStore.setToken(completion.access_token)
|
await authStore.setToken(completion.access_token)
|
||||||
appStore.showSuccess(t('auth.loginSuccess'))
|
appStore.showSuccess(t('auth.loginSuccess'))
|
||||||
await router.replace(redirect)
|
await router.replace(redirect)
|
||||||
@@ -236,12 +241,7 @@ async function handleSubmitInvitation() {
|
|||||||
invitationCode.value.trim(),
|
invitationCode.value.trim(),
|
||||||
currentAdoptionDecision()
|
currentAdoptionDecision()
|
||||||
)
|
)
|
||||||
if (tokenData.refresh_token) {
|
persistOAuthTokenContext(tokenData)
|
||||||
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
|
||||||
}
|
|
||||||
if (tokenData.expires_in) {
|
|
||||||
localStorage.setItem('token_expires_at', String(Date.now() + tokenData.expires_in * 1000))
|
|
||||||
}
|
|
||||||
await authStore.setToken(tokenData.access_token)
|
await authStore.setToken(tokenData.access_token)
|
||||||
appStore.showSuccess(t('auth.loginSuccess'))
|
appStore.showSuccess(t('auth.loginSuccess'))
|
||||||
await router.replace(redirectTo.value)
|
await router.replace(redirectTo.value)
|
||||||
@@ -258,7 +258,7 @@ async function handleContinueLogin() {
|
|||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
||||||
await finalizeLogin(completion, redirectTo.value)
|
await finalizeCompletion(completion, redirectTo.value)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||||
errorMessage.value =
|
errorMessage.value =
|
||||||
@@ -305,7 +305,7 @@ onMounted(async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await finalizeLogin(completion, redirect)
|
await finalizeCompletion(completion, redirect)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||||
errorMessage.value =
|
errorMessage.value =
|
||||||
|
|||||||
@@ -145,7 +145,10 @@ import { useAuthStore, useAppStore } from '@/stores'
|
|||||||
import {
|
import {
|
||||||
completeOIDCOAuthRegistration,
|
completeOIDCOAuthRegistration,
|
||||||
exchangePendingOAuthCompletion,
|
exchangePendingOAuthCompletion,
|
||||||
|
getOAuthCompletionKind,
|
||||||
getPublicSettings,
|
getPublicSettings,
|
||||||
|
isOAuthLoginCompletion,
|
||||||
|
persistOAuthTokenContext,
|
||||||
type OAuthAdoptionDecision,
|
type OAuthAdoptionDecision,
|
||||||
type PendingOAuthExchangeResponse
|
type PendingOAuthExchangeResponse
|
||||||
} from '@/api/auth'
|
} from '@/api/auth'
|
||||||
@@ -172,6 +175,7 @@ const suggestedAvatarUrl = ref('')
|
|||||||
const adoptDisplayName = ref(true)
|
const adoptDisplayName = ref(true)
|
||||||
const adoptAvatar = ref(true)
|
const adoptAvatar = ref(true)
|
||||||
const needsAdoptionConfirmation = ref(false)
|
const needsAdoptionConfirmation = ref(false)
|
||||||
|
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
||||||
|
|
||||||
function parseFragmentParams(): URLSearchParams {
|
function parseFragmentParams(): URLSearchParams {
|
||||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||||
@@ -231,18 +235,19 @@ function hasSuggestedProfile(completion: {
|
|||||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function finalizeLogin(completion: PendingOAuthExchangeResponse, redirect: string) {
|
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||||
if (!completion.access_token) {
|
if (getOAuthCompletionKind(completion) === 'bind') {
|
||||||
|
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
||||||
|
appStore.showSuccess(bindSuccessMessage)
|
||||||
|
await router.replace(bindRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOAuthLoginCompletion(completion)) {
|
||||||
throw new Error(t('auth.oidc.callbackMissingToken'))
|
throw new Error(t('auth.oidc.callbackMissingToken'))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completion.refresh_token) {
|
persistOAuthTokenContext(completion)
|
||||||
localStorage.setItem('refresh_token', completion.refresh_token)
|
|
||||||
}
|
|
||||||
if (completion.expires_in) {
|
|
||||||
localStorage.setItem('token_expires_at', String(Date.now() + completion.expires_in * 1000))
|
|
||||||
}
|
|
||||||
|
|
||||||
await authStore.setToken(completion.access_token)
|
await authStore.setToken(completion.access_token)
|
||||||
appStore.showSuccess(t('auth.loginSuccess'))
|
appStore.showSuccess(t('auth.loginSuccess'))
|
||||||
await router.replace(redirect)
|
await router.replace(redirect)
|
||||||
@@ -258,12 +263,7 @@ async function handleSubmitInvitation() {
|
|||||||
invitationCode.value.trim(),
|
invitationCode.value.trim(),
|
||||||
currentAdoptionDecision()
|
currentAdoptionDecision()
|
||||||
)
|
)
|
||||||
if (tokenData.refresh_token) {
|
persistOAuthTokenContext(tokenData)
|
||||||
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
|
||||||
}
|
|
||||||
if (tokenData.expires_in) {
|
|
||||||
localStorage.setItem('token_expires_at', String(Date.now() + tokenData.expires_in * 1000))
|
|
||||||
}
|
|
||||||
await authStore.setToken(tokenData.access_token)
|
await authStore.setToken(tokenData.access_token)
|
||||||
appStore.showSuccess(t('auth.loginSuccess'))
|
appStore.showSuccess(t('auth.loginSuccess'))
|
||||||
await router.replace(redirectTo.value)
|
await router.replace(redirectTo.value)
|
||||||
@@ -280,7 +280,7 @@ async function handleContinueLogin() {
|
|||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
||||||
await finalizeLogin(completion, redirectTo.value)
|
await finalizeCompletion(completion, redirectTo.value)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||||
errorMessage.value =
|
errorMessage.value =
|
||||||
@@ -329,7 +329,7 @@ onMounted(async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await finalizeLogin(completion, redirect)
|
await finalizeCompletion(completion, redirect)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||||
errorMessage.value =
|
errorMessage.value =
|
||||||
|
|||||||
@@ -140,27 +140,16 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { AuthLayout } from '@/components/layout'
|
import { AuthLayout } from '@/components/layout'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import { apiClient } from '@/api/client'
|
|
||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
|
import {
|
||||||
interface OAuthTokenResponse {
|
completeWeChatOAuthRegistration,
|
||||||
access_token: string
|
exchangePendingOAuthCompletion,
|
||||||
refresh_token: string
|
getOAuthCompletionKind,
|
||||||
expires_in: number
|
isOAuthLoginCompletion,
|
||||||
token_type: string
|
persistOAuthTokenContext,
|
||||||
}
|
type OAuthAdoptionDecision,
|
||||||
|
type PendingOAuthExchangeResponse
|
||||||
interface PendingOAuthExchangeResponse {
|
} from '@/api/auth'
|
||||||
access_token?: string
|
|
||||||
refresh_token?: string
|
|
||||||
expires_in?: number
|
|
||||||
token_type?: string
|
|
||||||
redirect?: string
|
|
||||||
error?: string
|
|
||||||
adoption_required?: boolean
|
|
||||||
suggested_display_name?: string
|
|
||||||
suggested_avatar_url?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -182,6 +171,7 @@ const suggestedAvatarUrl = ref('')
|
|||||||
const adoptDisplayName = ref(true)
|
const adoptDisplayName = ref(true)
|
||||||
const adoptAvatar = ref(true)
|
const adoptAvatar = ref(true)
|
||||||
const needsAdoptionConfirmation = ref(false)
|
const needsAdoptionConfirmation = ref(false)
|
||||||
|
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
||||||
|
|
||||||
const providerName = 'WeChat'
|
const providerName = 'WeChat'
|
||||||
|
|
||||||
@@ -200,10 +190,10 @@ function sanitizeRedirectPath(path: string | null | undefined): string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentAdoptionDecision(): Record<string, boolean> {
|
function currentAdoptionDecision(): OAuthAdoptionDecision {
|
||||||
return {
|
return {
|
||||||
adopt_display_name: adoptDisplayName.value,
|
adoptDisplayName: adoptDisplayName.value,
|
||||||
adopt_avatar: adoptAvatar.value,
|
adoptAvatar: adoptAvatar.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,49 +214,35 @@ function hasSuggestedProfile(completion: PendingOAuthExchangeResponse): boolean
|
|||||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exchangePendingOAuthCompletion(): Promise<PendingOAuthExchangeResponse> {
|
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||||
const { data } = await apiClient.post<PendingOAuthExchangeResponse>('/auth/oauth/pending/exchange', {})
|
if (getOAuthCompletionKind(completion) === 'bind') {
|
||||||
return data
|
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
||||||
}
|
appStore.showSuccess(bindSuccessMessage)
|
||||||
|
await router.replace(bindRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
async function finalizeLogin(completion: PendingOAuthExchangeResponse, redirect: string) {
|
if (!isOAuthLoginCompletion(completion)) {
|
||||||
if (!completion.access_token) {
|
|
||||||
throw new Error(t('auth.oidc.callbackMissingToken'))
|
throw new Error(t('auth.oidc.callbackMissingToken'))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completion.refresh_token) {
|
persistOAuthTokenContext(completion)
|
||||||
localStorage.setItem('refresh_token', completion.refresh_token)
|
|
||||||
}
|
|
||||||
if (completion.expires_in) {
|
|
||||||
localStorage.setItem('token_expires_at', String(Date.now() + completion.expires_in * 1000))
|
|
||||||
}
|
|
||||||
|
|
||||||
await authStore.setToken(completion.access_token)
|
await authStore.setToken(completion.access_token)
|
||||||
appStore.showSuccess(t('auth.loginSuccess'))
|
appStore.showSuccess(t('auth.loginSuccess'))
|
||||||
await router.replace(redirect)
|
await router.replace(redirect)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function completeWeChatOAuthRegistration(invitation: string): Promise<OAuthTokenResponse> {
|
|
||||||
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/wechat/complete-registration', {
|
|
||||||
invitation_code: invitation,
|
|
||||||
...currentAdoptionDecision(),
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmitInvitation() {
|
async function handleSubmitInvitation() {
|
||||||
invitationError.value = ''
|
invitationError.value = ''
|
||||||
if (!invitationCode.value.trim()) return
|
if (!invitationCode.value.trim()) return
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const tokenData = await completeWeChatOAuthRegistration(invitationCode.value.trim())
|
const tokenData = await completeWeChatOAuthRegistration(
|
||||||
if (tokenData.refresh_token) {
|
invitationCode.value.trim(),
|
||||||
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
currentAdoptionDecision()
|
||||||
}
|
)
|
||||||
if (tokenData.expires_in) {
|
persistOAuthTokenContext(tokenData)
|
||||||
localStorage.setItem('token_expires_at', String(Date.now() + tokenData.expires_in * 1000))
|
|
||||||
}
|
|
||||||
await authStore.setToken(tokenData.access_token)
|
await authStore.setToken(tokenData.access_token)
|
||||||
appStore.showSuccess(t('auth.loginSuccess'))
|
appStore.showSuccess(t('auth.loginSuccess'))
|
||||||
await router.replace(redirectTo.value)
|
await router.replace(redirectTo.value)
|
||||||
@@ -282,11 +258,8 @@ async function handleSubmitInvitation() {
|
|||||||
async function handleContinueLogin() {
|
async function handleContinueLogin() {
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.post<PendingOAuthExchangeResponse>(
|
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
||||||
'/auth/oauth/pending/exchange',
|
await finalizeCompletion(completion, redirectTo.value)
|
||||||
currentAdoptionDecision()
|
|
||||||
)
|
|
||||||
await finalizeLogin(data, redirectTo.value)
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||||
errorMessage.value =
|
errorMessage.value =
|
||||||
@@ -333,7 +306,7 @@ onMounted(async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await finalizeLogin(completion, redirect)
|
await finalizeCompletion(completion, redirect)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||||
errorMessage.value =
|
errorMessage.value =
|
||||||
|
|||||||
@@ -39,10 +39,14 @@ vi.mock('@/stores', () => ({
|
|||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/api/auth', () => ({
|
vi.mock('@/api/auth', async () => {
|
||||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
|
||||||
completeLinuxDoOAuthRegistration: (...args: any[]) => completeLinuxDoOAuthRegistration(...args)
|
return {
|
||||||
}))
|
...actual,
|
||||||
|
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
||||||
|
completeLinuxDoOAuthRegistration: (...args: any[]) => completeLinuxDoOAuthRegistration(...args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
describe('LinuxDoCallbackView', () => {
|
describe('LinuxDoCallbackView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -132,6 +136,64 @@ describe('LinuxDoCallbackView', () => {
|
|||||||
expect(replace).toHaveBeenCalledWith('/dashboard')
|
expect(replace).toHaveBeenCalledWith('/dashboard')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('treats a completion without token as bind success and returns to profile', async () => {
|
||||||
|
exchangePendingOAuthCompletion.mockResolvedValue({})
|
||||||
|
|
||||||
|
mount(LinuxDoCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(setToken).not.toHaveBeenCalled()
|
||||||
|
expect(showSuccess).toHaveBeenCalledWith('profile.authBindings.bindSuccess')
|
||||||
|
expect(replace).toHaveBeenCalledWith('/profile')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports bind completion after adoption confirmation', async () => {
|
||||||
|
exchangePendingOAuthCompletion
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
redirect: '/dashboard',
|
||||||
|
adoption_required: true,
|
||||||
|
suggested_display_name: 'LinuxDo Nick',
|
||||||
|
suggested_avatar_url: 'https://cdn.example/linuxdo.png'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
redirect: '/profile/security'
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(LinuxDoCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.findAll('button')[0].trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(exchangePendingOAuthCompletion).toHaveBeenNthCalledWith(2, {
|
||||||
|
adoptDisplayName: true,
|
||||||
|
adoptAvatar: true
|
||||||
|
})
|
||||||
|
expect(setToken).not.toHaveBeenCalled()
|
||||||
|
expect(showSuccess).toHaveBeenCalledWith('profile.authBindings.bindSuccess')
|
||||||
|
expect(replace).toHaveBeenCalledWith('/profile/security')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
||||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
error: 'invitation_required',
|
error: 'invitation_required',
|
||||||
|
|||||||
@@ -45,11 +45,15 @@ vi.mock('@/stores', () => ({
|
|||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/api/auth', () => ({
|
vi.mock('@/api/auth', async () => {
|
||||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
|
||||||
completeOIDCOAuthRegistration: (...args: any[]) => completeOIDCOAuthRegistration(...args),
|
return {
|
||||||
getPublicSettings: (...args: any[]) => getPublicSettings(...args)
|
...actual,
|
||||||
}))
|
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
||||||
|
completeOIDCOAuthRegistration: (...args: any[]) => completeOIDCOAuthRegistration(...args),
|
||||||
|
getPublicSettings: (...args: any[]) => getPublicSettings(...args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
describe('OidcCallbackView', () => {
|
describe('OidcCallbackView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -143,6 +147,43 @@ describe('OidcCallbackView', () => {
|
|||||||
expect(replace).toHaveBeenCalledWith('/dashboard')
|
expect(replace).toHaveBeenCalledWith('/dashboard')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('supports bind completion after adoption confirmation', async () => {
|
||||||
|
exchangePendingOAuthCompletion
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
redirect: '/dashboard',
|
||||||
|
adoption_required: true,
|
||||||
|
suggested_display_name: 'OIDC Nick',
|
||||||
|
suggested_avatar_url: 'https://cdn.example/oidc.png'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
redirect: '/profile'
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(OidcCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.findAll('button')[0].trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(exchangePendingOAuthCompletion).toHaveBeenNthCalledWith(2, {
|
||||||
|
adoptDisplayName: true,
|
||||||
|
adoptAvatar: true
|
||||||
|
})
|
||||||
|
expect(setToken).not.toHaveBeenCalled()
|
||||||
|
expect(showSuccess).toHaveBeenCalledWith('profile.authBindings.bindSuccess')
|
||||||
|
expect(replace).toHaveBeenCalledWith('/profile')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
||||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
error: 'invitation_required',
|
error: 'invitation_required',
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import WechatCallbackView from '@/views/auth/WechatCallbackView.vue'
|
import WechatCallbackView from '@/views/auth/WechatCallbackView.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
postMock,
|
exchangePendingOAuthCompletionMock,
|
||||||
|
completeWeChatOAuthRegistrationMock,
|
||||||
replaceMock,
|
replaceMock,
|
||||||
setTokenMock,
|
setTokenMock,
|
||||||
showSuccessMock,
|
showSuccessMock,
|
||||||
showErrorMock,
|
showErrorMock,
|
||||||
routeState,
|
routeState,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
postMock: vi.fn(),
|
exchangePendingOAuthCompletionMock: vi.fn(),
|
||||||
|
completeWeChatOAuthRegistrationMock: vi.fn(),
|
||||||
replaceMock: vi.fn(),
|
replaceMock: vi.fn(),
|
||||||
setTokenMock: vi.fn(),
|
setTokenMock: vi.fn(),
|
||||||
showSuccessMock: vi.fn(),
|
showSuccessMock: vi.fn(),
|
||||||
@@ -86,15 +88,19 @@ vi.mock('@/stores', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/api/client', () => ({
|
vi.mock('@/api/auth', async () => {
|
||||||
apiClient: {
|
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
|
||||||
post: postMock,
|
return {
|
||||||
},
|
...actual,
|
||||||
}))
|
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletionMock(...args),
|
||||||
|
completeWeChatOAuthRegistration: (...args: any[]) => completeWeChatOAuthRegistrationMock(...args),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
describe('WechatCallbackView', () => {
|
describe('WechatCallbackView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
postMock.mockReset()
|
exchangePendingOAuthCompletionMock.mockReset()
|
||||||
|
completeWeChatOAuthRegistrationMock.mockReset()
|
||||||
replaceMock.mockReset()
|
replaceMock.mockReset()
|
||||||
setTokenMock.mockReset()
|
setTokenMock.mockReset()
|
||||||
showSuccessMock.mockReset()
|
showSuccessMock.mockReset()
|
||||||
@@ -104,14 +110,12 @@ describe('WechatCallbackView', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('does not send adoption decisions during the initial exchange', async () => {
|
it('does not send adoption decisions during the initial exchange', async () => {
|
||||||
postMock.mockResolvedValueOnce({
|
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||||
data: {
|
access_token: 'access-token',
|
||||||
access_token: 'access-token',
|
refresh_token: 'refresh-token',
|
||||||
refresh_token: 'refresh-token',
|
expires_in: 3600,
|
||||||
expires_in: 3600,
|
redirect: '/dashboard',
|
||||||
redirect: '/dashboard',
|
adoption_required: true,
|
||||||
adoption_required: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
setTokenMock.mockResolvedValue({})
|
setTokenMock.mockResolvedValue({})
|
||||||
|
|
||||||
@@ -128,28 +132,24 @@ describe('WechatCallbackView', () => {
|
|||||||
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(postMock).toHaveBeenCalledWith('/auth/oauth/pending/exchange', {})
|
expect(exchangePendingOAuthCompletionMock).toHaveBeenCalledWith()
|
||||||
expect(postMock).toHaveBeenCalledTimes(1)
|
expect(exchangePendingOAuthCompletionMock).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('waits for explicit adoption confirmation before finishing a non-invitation login', async () => {
|
it('waits for explicit adoption confirmation before finishing a non-invitation login', async () => {
|
||||||
postMock
|
exchangePendingOAuthCompletionMock
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
data: {
|
redirect: '/dashboard',
|
||||||
redirect: '/dashboard',
|
adoption_required: true,
|
||||||
adoption_required: true,
|
suggested_display_name: 'WeChat Nick',
|
||||||
suggested_display_name: 'WeChat Nick',
|
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||||
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
data: {
|
access_token: 'wechat-access-token',
|
||||||
access_token: 'wechat-access-token',
|
refresh_token: 'wechat-refresh-token',
|
||||||
refresh_token: 'wechat-refresh-token',
|
expires_in: 3600,
|
||||||
expires_in: 3600,
|
token_type: 'Bearer',
|
||||||
token_type: 'Bearer',
|
redirect: '/dashboard',
|
||||||
redirect: '/dashboard',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
setTokenMock.mockResolvedValue({})
|
setTokenMock.mockResolvedValue({})
|
||||||
|
|
||||||
@@ -179,35 +179,67 @@ describe('WechatCallbackView', () => {
|
|||||||
await buttons[0].trigger('click')
|
await buttons[0].trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(postMock).toHaveBeenNthCalledWith(1, '/auth/oauth/pending/exchange', {})
|
expect(exchangePendingOAuthCompletionMock).toHaveBeenNthCalledWith(1)
|
||||||
expect(postMock).toHaveBeenNthCalledWith(2, '/auth/oauth/pending/exchange', {
|
expect(exchangePendingOAuthCompletionMock).toHaveBeenNthCalledWith(2, {
|
||||||
adopt_display_name: true,
|
adoptDisplayName: true,
|
||||||
adopt_avatar: false,
|
adoptAvatar: false,
|
||||||
})
|
})
|
||||||
expect(setTokenMock).toHaveBeenCalledWith('wechat-access-token')
|
expect(setTokenMock).toHaveBeenCalledWith('wechat-access-token')
|
||||||
expect(replaceMock).toHaveBeenCalledWith('/dashboard')
|
expect(replaceMock).toHaveBeenCalledWith('/dashboard')
|
||||||
expect(localStorage.getItem('refresh_token')).toBe('wechat-refresh-token')
|
expect(localStorage.getItem('refresh_token')).toBe('wechat-refresh-token')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('supports bind completion after adoption confirmation', async () => {
|
||||||
|
exchangePendingOAuthCompletionMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
redirect: '/dashboard',
|
||||||
|
adoption_required: true,
|
||||||
|
suggested_display_name: 'WeChat Nick',
|
||||||
|
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
redirect: '/profile/connections',
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(WechatCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.findAll('button')[0].trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(exchangePendingOAuthCompletionMock).toHaveBeenNthCalledWith(2, {
|
||||||
|
adoptDisplayName: true,
|
||||||
|
adoptAvatar: true,
|
||||||
|
})
|
||||||
|
expect(setTokenMock).not.toHaveBeenCalled()
|
||||||
|
expect(showSuccessMock).toHaveBeenCalledWith('profile.authBindings.bindSuccess')
|
||||||
|
expect(replaceMock).toHaveBeenCalledWith('/profile/connections')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
||||||
postMock
|
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||||
.mockResolvedValueOnce({
|
error: 'invitation_required',
|
||||||
data: {
|
redirect: '/subscriptions',
|
||||||
error: 'invitation_required',
|
adoption_required: true,
|
||||||
redirect: '/subscriptions',
|
suggested_display_name: 'WeChat Nick',
|
||||||
adoption_required: true,
|
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||||
suggested_display_name: 'WeChat Nick',
|
})
|
||||||
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
completeWeChatOAuthRegistrationMock.mockResolvedValue({
|
||||||
},
|
access_token: 'wechat-invite-token',
|
||||||
})
|
refresh_token: 'wechat-invite-refresh',
|
||||||
.mockResolvedValueOnce({
|
expires_in: 600,
|
||||||
data: {
|
token_type: 'Bearer',
|
||||||
access_token: 'wechat-invite-token',
|
})
|
||||||
refresh_token: 'wechat-invite-refresh',
|
|
||||||
expires_in: 600,
|
|
||||||
token_type: 'Bearer',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const wrapper = mount(WechatCallbackView, {
|
const wrapper = mount(WechatCallbackView, {
|
||||||
global: {
|
global: {
|
||||||
@@ -230,10 +262,9 @@ describe('WechatCallbackView', () => {
|
|||||||
await wrapper.get('button').trigger('click')
|
await wrapper.get('button').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(postMock).toHaveBeenNthCalledWith(2, '/auth/oauth/wechat/complete-registration', {
|
expect(completeWeChatOAuthRegistrationMock).toHaveBeenCalledWith('INVITE-CODE', {
|
||||||
invitation_code: 'INVITE-CODE',
|
adoptDisplayName: false,
|
||||||
adopt_display_name: false,
|
adoptAvatar: true,
|
||||||
adopt_avatar: true,
|
|
||||||
})
|
})
|
||||||
expect(setTokenMock).toHaveBeenCalledWith('wechat-invite-token')
|
expect(setTokenMock).toHaveBeenCalledWith('wechat-invite-token')
|
||||||
expect(replaceMock).toHaveBeenCalledWith('/subscriptions')
|
expect(replaceMock).toHaveBeenCalledWith('/subscriptions')
|
||||||
|
|||||||
@@ -2,18 +2,53 @@
|
|||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div class="mx-auto max-w-4xl space-y-6">
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||||
<StatCard :title="t('profile.accountBalance')" :value="formatCurrency(user?.balance || 0)" :icon="WalletIcon" icon-variant="success" />
|
<StatCard
|
||||||
<StatCard :title="t('profile.concurrencyLimit')" :value="user?.concurrency || 0" :icon="BoltIcon" icon-variant="warning" />
|
:title="t('profile.accountBalance')"
|
||||||
<StatCard :title="t('profile.memberSince')" :value="formatDate(user?.created_at || '', { year: 'numeric', month: 'long' })" :icon="CalendarIcon" icon-variant="primary" />
|
:value="formatCurrency(user?.balance || 0)"
|
||||||
|
:icon="WalletIcon"
|
||||||
|
icon-variant="success"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
:title="t('profile.concurrencyLimit')"
|
||||||
|
:value="user?.concurrency || 0"
|
||||||
|
:icon="BoltIcon"
|
||||||
|
icon-variant="warning"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
:title="t('profile.memberSince')"
|
||||||
|
:value="formatDate(user?.created_at || '', { year: 'numeric', month: 'long' })"
|
||||||
|
:icon="CalendarIcon"
|
||||||
|
icon-variant="primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ProfileInfoCard :user="user" />
|
|
||||||
<div v-if="contactInfo" class="card border-primary-200 bg-primary-50 dark:bg-primary-900/20 p-6">
|
<ProfileInfoCard
|
||||||
|
:user="user"
|
||||||
|
:linuxdo-enabled="linuxdoOAuthEnabled"
|
||||||
|
:oidc-enabled="oidcOAuthEnabled"
|
||||||
|
:oidc-provider-name="oidcOAuthProviderName"
|
||||||
|
:wechat-enabled="wechatOAuthEnabled"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="contactInfo"
|
||||||
|
class="card border-primary-200 bg-primary-50 p-6 dark:bg-primary-900/20"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="p-3 bg-primary-100 rounded-xl text-primary-600"><Icon name="chat" size="lg" /></div>
|
<div class="rounded-xl bg-primary-100 p-3 text-primary-600">
|
||||||
<div><h3 class="font-semibold text-primary-800 dark:text-primary-200">{{ t('common.contactSupport') }}</h3><p class="text-sm font-medium">{{ contactInfo }}</p></div>
|
<Icon name="chat" size="lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-primary-800 dark:text-primary-200">
|
||||||
|
{{ t('common.contactSupport') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm font-medium">{{ contactInfo }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProfileEditForm :initial-username="user?.username || ''" />
|
<ProfileEditForm :initial-username="user?.username || ''" />
|
||||||
|
|
||||||
<ProfileBalanceNotifyCard
|
<ProfileBalanceNotifyCard
|
||||||
v-if="user && balanceLowNotifyEnabled"
|
v-if="user && balanceLowNotifyEnabled"
|
||||||
:enabled="user.balance_notify_enabled ?? true"
|
:enabled="user.balance_notify_enabled ?? true"
|
||||||
@@ -22,6 +57,7 @@
|
|||||||
:system-default-threshold="systemDefaultThreshold"
|
:system-default-threshold="systemDefaultThreshold"
|
||||||
:user-email="user.email"
|
:user-email="user.email"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProfilePasswordForm />
|
<ProfilePasswordForm />
|
||||||
<ProfileTotpCard />
|
<ProfileTotpCard />
|
||||||
</div>
|
</div>
|
||||||
@@ -29,26 +65,78 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, h, onMounted } from 'vue'; import { useI18n } from 'vue-i18n'
|
import { computed, h, onMounted, ref } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'; import { formatDate } from '@/utils/format'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { authAPI } from '@/api'; import AppLayout from '@/components/layout/AppLayout.vue'
|
import { authAPI } from '@/api'
|
||||||
|
import { Icon } from '@/components/icons'
|
||||||
import StatCard from '@/components/common/StatCard.vue'
|
import StatCard from '@/components/common/StatCard.vue'
|
||||||
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
|
|
||||||
import ProfileBalanceNotifyCard from '@/components/user/profile/ProfileBalanceNotifyCard.vue'
|
import ProfileBalanceNotifyCard from '@/components/user/profile/ProfileBalanceNotifyCard.vue'
|
||||||
|
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
|
||||||
|
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
|
||||||
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
|
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
|
||||||
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
|
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
|
||||||
import { Icon } from '@/components/icons'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { formatDate } from '@/utils/format'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const user = computed(() => authStore.user)
|
||||||
|
|
||||||
const { t } = useI18n(); const authStore = useAuthStore(); const user = computed(() => authStore.user)
|
|
||||||
const contactInfo = ref('')
|
const contactInfo = ref('')
|
||||||
const balanceLowNotifyEnabled = ref(false)
|
const balanceLowNotifyEnabled = ref(false)
|
||||||
const systemDefaultThreshold = ref(0)
|
const systemDefaultThreshold = ref(0)
|
||||||
|
const linuxdoOAuthEnabled = ref(false)
|
||||||
|
const wechatOAuthEnabled = ref(false)
|
||||||
|
const oidcOAuthEnabled = ref(false)
|
||||||
|
const oidcOAuthProviderName = ref('OIDC')
|
||||||
|
|
||||||
const WalletIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12' })]) }
|
const WalletIcon = {
|
||||||
const BoltIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })]) }
|
render: () =>
|
||||||
const CalendarIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M6.75 3v2.25M17.25 3v2.25' })]) }
|
h(
|
||||||
|
'svg',
|
||||||
|
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||||
|
[h('path', { d: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12' })]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const BoltIcon = {
|
||||||
|
render: () =>
|
||||||
|
h(
|
||||||
|
'svg',
|
||||||
|
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||||
|
[h('path', { d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const CalendarIcon = {
|
||||||
|
render: () =>
|
||||||
|
h(
|
||||||
|
'svg',
|
||||||
|
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||||
|
[h('path', { d: 'M6.75 3v2.25M17.25 3v2.25' })]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => { try { const s = await authAPI.getPublicSettings(); contactInfo.value = s.contact_info || ''; balanceLowNotifyEnabled.value = s.balance_low_notify_enabled ?? false; systemDefaultThreshold.value = s.balance_low_notify_threshold ?? 0 } catch (error) { console.error('Failed to load settings:', error) } })
|
onMounted(async () => {
|
||||||
const formatCurrency = (v: number) => `$${v.toFixed(2)}`
|
const profileRefresh = authStore.refreshUser().catch((error) => {
|
||||||
|
console.error('Failed to refresh profile:', error)
|
||||||
|
})
|
||||||
|
|
||||||
|
const settingsLoad = authAPI.getPublicSettings()
|
||||||
|
.then((settings) => {
|
||||||
|
contactInfo.value = settings.contact_info || ''
|
||||||
|
balanceLowNotifyEnabled.value = settings.balance_low_notify_enabled ?? false
|
||||||
|
systemDefaultThreshold.value = settings.balance_low_notify_threshold ?? 0
|
||||||
|
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled ?? false
|
||||||
|
wechatOAuthEnabled.value = settings.wechat_oauth_enabled ?? false
|
||||||
|
oidcOAuthEnabled.value = settings.oidc_oauth_enabled ?? false
|
||||||
|
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load settings:', error)
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([profileRefresh, settingsLoad])
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => `$${value.toFixed(2)}`
|
||||||
</script>
|
</script>
|
||||||
Reference in New Issue
Block a user