Skip to content
Merged

fix #46

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions controller/authz.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package controller

import (
"net/http"

"github.com/MAX-API-Next/MAX-API/service/authz"
"github.com/gin-gonic/gin"
)

// GetPermissionCatalog returns the stable resource/action schema used by
// administrative clients. It is read-only; policy mutations are intentionally
// deferred until the authorization editor and audit contract are complete.
func GetPermissionCatalog(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"resources": authz.Catalog(),
"roles": authz.Roles(),
},
})
}
21 changes: 21 additions & 0 deletions controller/authz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package controller

import (
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)

func TestGetPermissionCatalogReturnsResourcesAndRoleBaselines(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
GetPermissionCatalog(c)
assert.Equal(t, 200, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"resources"`)
assert.Contains(t, recorder.Body.String(), `"channel"`)
assert.Contains(t, recorder.Body.String(), `"roles"`)
assert.Contains(t, recorder.Body.String(), `"superuser":true`)
}
164 changes: 121 additions & 43 deletions controller/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ import (
)

const (
oauthAuthFlowTTL = 10 * time.Minute
oauthStateSessionKey = "oauth_state"
oauthAuthFlowTTL = 10 * time.Minute
oauthStateSessionKey = "oauth_state"
authSessionIDSessionKey = "auth_session_id"
)

type oauthStateRequest struct {
Expand Down Expand Up @@ -49,6 +50,33 @@ func clearOAuthSessionState(c *gin.Context) {
}
}

func authSessionID(session sessions.Session) string {
value, ok := session.Get(authSessionIDSessionKey).(string)
if !ok {
return ""
}
value = strings.TrimSpace(value)
if len(value) != 48 {
return ""
}
return value
}

func ensureAuthSessionID(session sessions.Session) (string, error) {
if session == nil {
return "", errors.New("auth session is unavailable")
}
if value := authSessionID(session); value != "" {
return value, nil
}
value, err := common.GenerateRandomKey(48)
if err != nil {
return "", fmt.Errorf("generate auth session id: %w", err)
}
session.Set(authSessionIDSessionKey, value)
return value, nil
}

// providerParams returns map with Provider key for i18n templates
func providerParams(name string) map[string]any {
return map[string]any{"Provider": name}
Expand Down Expand Up @@ -142,6 +170,12 @@ func GenerateOAuthCode(c *gin.Context) {
return
}
}
session := sessions.Default(c)
sessionID, err := ensureAuthSessionID(session)
if err != nil {
common.ApiError(c, err)
return
}
payload, err := common.Marshal(oauthFlowPayload{AffiliateCode: request.Aff})
if err != nil {
common.ApiError(c, err)
Expand All @@ -153,21 +187,22 @@ func GenerateOAuthCode(c *gin.Context) {
Provider: request.Provider,
Intent: request.Intent,
UserId: userID,
SessionId: sessionID,
Payload: string(payload),
ExpiresAt: expiresAt,
})
if err != nil {
common.ApiError(c, err)
return
}
session := sessions.Default(c)
session.Set(oauthStateSessionKey, state)
if err := session.Save(); err != nil {
if _, consumeErr := model.ConsumeAuthFlow(state, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: request.Provider,
Intent: request.Intent,
UserId: userID,
Purpose: model.AuthFlowPurposeOAuth,
Provider: request.Provider,
Intent: request.Intent,
UserId: userID,
SessionId: sessionID,
}); consumeErr != nil {
common.SysError("failed to invalidate OAuth flow after session save failure: " + consumeErr.Error())
}
Expand Down Expand Up @@ -213,11 +248,20 @@ func HandleOAuth(c *gin.Context) {
})
return
}
sessionID := authSessionID(sessions.Default(c))
if pendingFlow.SessionId != "" && pendingFlow.SessionId != sessionID {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": i18n.T(c, i18n.MsgOAuthStateInvalid),
})
return
}

consumeMatch := model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: providerName,
Intent: pendingFlow.Intent,
Purpose: model.AuthFlowPurposeOAuth,
Provider: providerName,
Intent: pendingFlow.Intent,
SessionId: pendingFlow.SessionId,
}
if pendingFlow.Intent == model.AuthFlowIntentBind {
userID := c.GetInt("id")
Expand Down Expand Up @@ -355,45 +399,68 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider, pendingFlow *model
}
}

if _, err := model.ConsumeAuthFlow(flowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: pendingFlow.Provider,
Intent: model.AuthFlowIntentBind,
UserId: pendingFlow.UserId,
}); err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
return
}
clearOAuthSessionState(c)
user := model.User{Id: pendingFlow.UserId}
err = user.FillUserById()
if err != nil {
common.ApiError(c, err)
// Resolve the mutation path before consuming the one-time flow so an
// unsupported provider can never burn a valid state.
genericProvider, isGenericProvider := provider.(*oauth.GenericOAuthProvider)
updateField, hasUpdateField := oauthProviderUserUpdateField(provider)
if !isGenericProvider && !hasUpdateField {
common.ApiError(c, fmt.Errorf("unsupported built-in OAuth provider: %s", provider.GetName()))
return
}

// Handle binding based on provider type
if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok {
// Custom provider: use user_oauth_bindings table
err = model.UpdateUserOAuthBinding(user.Id, genericProvider.GetProviderId(), oauthUser.ProviderUserID)
if err != nil {
common.ApiError(c, err)
return
}
} else {
// Built-in provider: update user record directly
provider.SetProviderUserID(&user, oauthUser.ProviderUserID)
updateField, ok := oauthProviderUserUpdateField(provider)
if !ok {
common.ApiError(c, fmt.Errorf("unsupported built-in OAuth provider: %s", provider.GetName()))
return
}
err = user.UpdateFields(false, updateField)
if err != nil {
common.ApiError(c, err)
var cacheTask model.CacheInvalidationTask
bindMatch := model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: pendingFlow.Provider,
Intent: model.AuthFlowIntentBind,
UserId: pendingFlow.UserId,
SessionId: pendingFlow.SessionId,
}
err = model.WithUserOAuthIdentityMutationLock(nil, func(conn *gorm.DB) error {
return conn.Transaction(func(tx *gorm.DB) error {
_, consumeErr := model.ConsumeAuthFlowWithActionTx(tx, flowToken, bindMatch, func(identityTx *gorm.DB, _ *model.AuthFlow) error {
var user model.User
if err := identityTx.First(&user, pendingFlow.UserId).Error; err != nil {
return err
}
if isGenericProvider {
// Custom provider: keep the binding mutation in the same transaction
// as AuthFlow consumption.
return model.UpdateUserOAuthBindingWithTx(identityTx, user.Id, genericProvider.GetProviderId(), oauthUser.ProviderUserID)
}
if taken, err := model.IsOAuthIdentityTakenWithTx(identityTx, updateField, oauthUser.ProviderUserID, user.Id); err != nil {
return err
} else if taken {
return model.ErrOAuthIdentityAlreadyTaken
}
if legacyID, ok := oauthUser.Extra["legacy_id"].(string); ok && strings.TrimSpace(legacyID) != "" {
if taken, err := model.IsOAuthIdentityTakenWithTx(identityTx, updateField, legacyID, user.Id); err != nil {
return err
} else if taken {
return model.ErrOAuthIdentityAlreadyTaken
}
}

// Built-in provider: update the user identity and durable cache fence
// in the same transaction as AuthFlow consumption.
provider.SetProviderUserID(&user, oauthUser.ProviderUserID)
var updateErr error
cacheTask, updateErr = user.UpdateFieldsWithTxAndCache(identityTx, false, updateField)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return updateErr
})
return consumeErr
})
})
if err != nil {
if errors.Is(err, model.ErrOAuthIdentityAlreadyTaken) {
common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName()))
return
}
handleAuthFlowConsumeError(c, err)
return
}
model.DispatchStagedCacheInvalidation(cacheTask)
clearOAuthSessionState(c)

common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{
"action": "bind",
Expand Down Expand Up @@ -593,3 +660,14 @@ func handleOAuthError(c *gin.Context, err error) {
common.ApiError(c, err)
}
}

func handleAuthFlowConsumeError(c *gin.Context, err error) {
switch {
case errors.Is(err, model.ErrAuthFlowInvalid),
errors.Is(err, model.ErrAuthFlowExpired),
errors.Is(err, model.ErrAuthFlowConsumed):
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
default:
common.ApiError(c, err)
}
}
73 changes: 73 additions & 0 deletions controller/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,56 @@ func TestGenerateOAuthCodeCreatesProviderBoundOpaqueState(t *testing.T) {
require.NoError(t, err)
require.NotEqual(t, response.Data, flow.TokenHash)
require.Contains(t, flow.Payload, "invite")
require.Len(t, flow.SessionId, 48)
}

func TestHandleOAuthRejectsSessionBoundFlowFromModifiedSession(t *testing.T) {
setupControllerAuthFlowDB(t)
require.NoError(t, appi18n.Init())
originalGitHubOAuthEnabled := common.GitHubOAuthEnabled
common.GitHubOAuthEnabled = true
t.Cleanup(func() { common.GitHubOAuthEnabled = originalGitHubOAuthEnabled })

router := gin.New()
router.Use(sessions.Sessions("session", cookie.NewStore([]byte("oauth-session-binding-test"))))
router.POST("/api/oauth/state", GenerateOAuthCode)
router.GET("/tamper", func(c *gin.Context) {
session := sessions.Default(c)
session.Set(authSessionIDSessionKey, strings.Repeat("x", 48))
require.NoError(t, session.Save())
c.Status(http.StatusNoContent)
})
router.GET("/api/oauth/:provider", HandleOAuth)

stateRecorder := httptest.NewRecorder()
stateRequest := httptest.NewRequest(http.MethodPost, "/api/oauth/state", strings.NewReader(`{"provider":"github","intent":"login"}`))
stateRequest.Header.Set("Content-Type", "application/json")
router.ServeHTTP(stateRecorder, stateRequest)
require.Equal(t, http.StatusOK, stateRecorder.Code)
var stateResponse struct {
Success bool `json:"success"`
Data string `json:"data"`
}
require.NoError(t, common.Unmarshal(stateRecorder.Body.Bytes(), &stateResponse))
require.True(t, stateResponse.Success)

tamperRequest := httptest.NewRequest(http.MethodGet, "/tamper", nil)
for _, sessionCookie := range stateRecorder.Result().Cookies() {
tamperRequest.AddCookie(sessionCookie)
}
tamperRecorder := httptest.NewRecorder()
router.ServeHTTP(tamperRecorder, tamperRequest)
require.Equal(t, http.StatusNoContent, tamperRecorder.Code)

callbackRequest := httptest.NewRequest(http.MethodGet, "/api/oauth/github?state="+stateResponse.Data+"&error=access_denied", nil)
for _, sessionCookie := range tamperRecorder.Result().Cookies() {
callbackRequest.AddCookie(sessionCookie)
}
callbackRecorder := httptest.NewRecorder()
router.ServeHTTP(callbackRecorder, callbackRequest)
require.Equal(t, http.StatusForbidden, callbackRecorder.Code)
_, err := model.GetAuthFlow(stateResponse.Data, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth, Provider: "github"})
require.NoError(t, err)
}

func TestHandleOAuthRejectsStateFromDifferentBrowserSession(t *testing.T) {
Expand Down Expand Up @@ -290,6 +340,29 @@ func TestOAuthProviderUserUpdateField(t *testing.T) {
}
}

func TestHandleAuthFlowConsumeErrorDistinguishesValidationAndInfrastructure(t *testing.T) {
require.NoError(t, appi18n.Init())

for _, validationErr := range []error{
model.ErrAuthFlowInvalid,
model.ErrAuthFlowExpired,
model.ErrAuthFlowConsumed,
} {
validationRecorder := httptest.NewRecorder()
validationContext, _ := gin.CreateTestContext(validationRecorder)
validationContext.Request = httptest.NewRequest(http.MethodGet, "/", nil)
handleAuthFlowConsumeError(validationContext, validationErr)
require.Equal(t, http.StatusForbidden, validationRecorder.Code)
}

infrastructureRecorder := httptest.NewRecorder()
infrastructureContext, _ := gin.CreateTestContext(infrastructureRecorder)
infrastructureContext.Request = httptest.NewRequest(http.MethodGet, "/", nil)
handleAuthFlowConsumeError(infrastructureContext, errors.New("database unavailable"))
require.Equal(t, http.StatusOK, infrastructureRecorder.Code)
require.Contains(t, infrastructureRecorder.Body.String(), "database unavailable")
}

func TestFindOrCreateOAuthUserMapsDeletedUserDomainError(t *testing.T) {
_, err := findOrCreateOAuthUser(nil, &deletedUserOAuthProvider{}, &oauth.OAuthUser{
ProviderUserID: "deleted-user-id",
Expand Down
Loading
Loading