Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ Application roles are derived from claims, never defaulted to admin:

##### SSO-Only Mode & Break-Glass

When `passwordLoginEnabled: false` and OIDC is the only staff transport, `/dashboard/login` auto-redirects to the provider. The redirect is suppressed when the provider bounced back with `?oidcError=` (prevents a loop), in WxWork-only environments, or via `?direct=1` with the break-glass allowlist configured (`auth.breakGlassEmails`, env `BREAK_GLASS_LOGIN_EMAILS`): allowlisted admin emails keep password login reachable for IdP outages. In this mode the default-password bootstrap admin (`admin` / `ChangeMe123!`) is not seeded, and its emailless account can never pass the email-only allowlist. The support portal is never auto-redirected (it serves customer accounts); note that customer portal sign-in shares the staff password endpoint, so in SSO-only mode only break-glass principals can password-sign in there.
When `passwordLoginEnabled: false` and OIDC is the only staff transport, `/dashboard/login` auto-redirects to the provider. The redirect is suppressed when the provider bounced back with `?oidcError=` (prevents a loop), in WxWork-only environments, or via `?direct=1` with the break-glass allowlist configured (`auth.breakGlassEmails`, env `BREAK_GLASS_LOGIN_EMAILS`): allowlisted admin emails keep password login reachable for IdP outages. In this mode the default-password bootstrap admin (`admin` / `ChangeMe123!`) is not seeded, and its emailless account can never pass the email-only allowlist. The support portal follows the same rule: with OIDC as its only enabled transport, `/support/login` auto-redirects as well, and portal OIDC logins provision customer-type users (see the next section), so the redirect can never mint staff accounts. A failed round-trip bounces back to the surface that started it, `/support/login?oidcError=` for portal targets and `/dashboard/login?oidcError=` otherwise, so the error guard cannot loop across surfaces. The break-glass `?direct=1` form stays reachable on both surfaces, and while password login is disabled the password-mutation endpoints (`/api/dashboard/user/reset_password`, `/api/dashboard/change_password`) reject any target that is not on the break-glass allowlist, so an SSO-only deployment never accumulates dormant local passwords that would come alive if the switch were flipped back.

##### Portal OIDC Login Provisions Customers

Expand Down
23 changes: 19 additions & 4 deletions internal/handlers/api/auth_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,10 @@ func WxWorkExchange(ctx *gin.Context) {
}

func OIDCLogin(ctx *gin.Context) {
loginURL, err := services.OIDCLoginService.BuildOIDCLoginURL(ctx.Query("next"))
next := ctx.Query("next")
loginURL, err := services.OIDCLoginService.BuildOIDCLoginURL(next)
Comment on lines +103 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The next parameter is retrieved directly from the query string and passed to the OIDC login flow without validation. If an attacker provides a protocol-relative URL (e.g., //evil.com) or a path traversal sequence (e.g., /support/..//evil.com), it can bypass basic prefix checks on the frontend and lead to an Open Redirect vulnerability upon successful authentication. Sanitizing the next parameter here ensures that only safe local paths are processed.

	next := ctx.Query("next")
	if next != "" {
		if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") || strings.Contains(next, "..") || strings.Contains(next, "\\") {
			if services.IsSupportPortalNext(next) {
				next = "/support"
			} else {
				next = "/dashboard"
			}
		}
	}
	loginURL, err := services.OIDCLoginService.BuildOIDCLoginURL(next)

if err != nil {
ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(loginErrorMessage(err.Error())))
ctx.Redirect(http.StatusFound, oidcLoginErrorRedirect(next, loginErrorMessage(err.Error())))
return
}
ctx.Redirect(http.StatusFound, loginURL)
Expand All @@ -116,7 +117,8 @@ func OIDCCallback(ctx *gin.Context) {
if desc != "" {
errMsg += ": " + desc
}
ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(errMsg))
ctx.Redirect(http.StatusFound, oidcLoginErrorRedirect(
services.OIDCLoginService.NextFromState(ctx.Query("state")), errMsg))
return
}

Expand All @@ -137,7 +139,8 @@ func OIDCCallback(ctx *gin.Context) {
)
if err != nil {
slog.Error("oidc callback login failed", "error", err)
ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(loginErrorMessage(err.Error())))
ctx.Redirect(http.StatusFound, oidcLoginErrorRedirect(
services.OIDCLoginService.NextFromState(ctx.Query("state")), loginErrorMessage(err.Error())))
return
}
ctx.Redirect(http.StatusFound, "/dashboard/login/oidc/callback?ticket="+url.QueryEscape(ticket)+"&next="+url.QueryEscape(next))
Expand Down Expand Up @@ -217,6 +220,18 @@ func wxWorkErrorMessage(message string) string {
return loginErrorMessage(message)
}

// oidcLoginErrorRedirect routes a failed OIDC round-trip back to the login
// surface that started it: the support portal for /support/* targets, the
// staff dashboard otherwise. This keeps the IdP error bounce and the
// auto-redirect loop guard on the same page.
func oidcLoginErrorRedirect(next, message string) string {
loginPath := "/dashboard/login"
if services.IsSupportPortalNext(next) {
loginPath = "/support/login"
}
return loginPath + "?oidcError=" + url.QueryEscape(message)
}

func loginErrorMessage(message string) string {
if idx := strings.Index(message, ": "); idx >= 0 {
message = message[idx+2:]
Expand Down
41 changes: 41 additions & 0 deletions internal/handlers/api/auth_handler_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package api

import (
"net/url"
"testing"
)

// A failed OIDC round-trip must bounce back to the login surface that started
// it: the support portal for /support/* targets, the staff dashboard
// otherwise. Routing a portal failure to the staff page would strand the
// customer there, and retrying from that page would provision them as an
// employee instead of a customer.
func TestOIDCLoginErrorRedirectTargetsOriginSurface(t *testing.T) {
const plainMessage = "sign-in failed"
cases := []struct {
name string
next string
message string
portal bool
}{
{name: "staff target", next: "/dashboard", message: plainMessage},
{name: "empty next falls back to staff", next: "", message: plainMessage},
{name: "portal path", next: "/support/community", message: plainMessage, portal: true},
{name: "portal root", next: "/support", message: plainMessage, portal: true},
{name: "padded portal path", next: " /support/tickets", message: plainMessage, portal: true},
{name: "portal lookalike is not portal", next: "/supportevil", message: plainMessage},
{name: "untrusted next is never reflected", next: "https://evil.example", message: plainMessage},
{name: "message is escaped", next: "/support", message: "boom & breakdown", portal: true},
}

for _, tc := range cases {
loginPath := "/dashboard/login"
if tc.portal {
loginPath = "/support/login"
}
expected := loginPath + "?oidcError=" + url.QueryEscape(tc.message)
if got := oidcLoginErrorRedirect(tc.next, tc.message); got != expected {
t.Fatalf("%s: oidcLoginErrorRedirect(%q) = %q, want %q", tc.name, tc.next, got, expected)
}
}
}
5 changes: 3 additions & 2 deletions internal/handlers/dashboard/user_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package dashboard

import (
"agent-desk/internal/builders"
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
Expand Down Expand Up @@ -171,7 +172,7 @@ func UserPostReset_password(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
password, err := services.UserService.ResetPassword(req.UserID, operator)
password, err := services.UserService.ResetPassword(req.UserID, operator, config.Current().Auth)
if err != nil {
httpx.WriteJSON(ctx, err)
return
Expand Down Expand Up @@ -200,7 +201,7 @@ func UserPostChange_password(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
if err := services.UserService.ChangeOwnPassword(req.Password, principal); err != nil {
if err := services.UserService.ChangeOwnPassword(req.Password, principal, config.Current().Auth); err != nil {
httpx.WriteJSON(ctx, err)
return
}
Expand Down
1 change: 1 addition & 0 deletions internal/pkg/i18nx/locales/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ error.e0231: "No matching WeCom channel was found."
error.e0232: "No schedules were generated."
error.auth.expired: "Your session has expired. Please sign in again."
error.auth.passwordLoginDisabled: "Username and password login is disabled. Please use SSO to sign in."
error.auth.passwordChangeDisabled: "Password sign-in is disabled, so only break-glass accounts may keep a local password."
error.auth.invalidSignature: "Invalid request signature or secret token."
error.e0234: "No available AI configuration is configured."
error.e0235: "No available embedding model is configured."
Expand Down
1 change: 1 addition & 0 deletions internal/pkg/i18nx/locales/zh-CN.yml
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ error.e0231: "未找到匹配的企业微信接入渠道"
error.e0232: "未生成任何排班"
error.auth.expired: "未登录或登录已过期"
error.auth.passwordLoginDisabled: "账号密码登录已禁用,请使用第三方登录。"
error.auth.passwordChangeDisabled: "账号密码登录已禁用,仅 break-glass 应急账号可保留本地密码。"
error.auth.invalidSignature: "请求签名或凭证 Token 无效"
error.e0234: "未配置可用的 AI 配置"
error.e0235: "未配置可用的 Embedding 模型"
Expand Down
21 changes: 17 additions & 4 deletions internal/services/oidc_login_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func (s *oidcLoginService) LoginByOIDC(ctx context.Context, code, state string,
if err != nil {
return "", "", err
}
loginResp, err := s.loginWithOIDCProfile(profile, authCfg, clientIP, userAgent, isSupportPortalNext(next))
loginResp, err := s.loginWithOIDCProfile(profile, authCfg, clientIP, userAgent, IsSupportPortalNext(next))
if err != nil {
return "", "", err
}
Expand All @@ -62,13 +62,26 @@ func (s *oidcLoginService) ExchangeOIDCLoginTicket(ticket string) (*response.Log
return oidcclient.ConsumeLoginTicket(ticket)
}

// isSupportPortalNext reports whether the OIDC round-trip started from the
// NextFromState recovers the sanitized redirect target carried by a signed
// OIDC state. Handlers use it after a failed round-trip so an IdP error
// bounces back to the login surface that started the flow. It returns an
// empty string when the state is missing, expired, or fails verification.
func (s *oidcLoginService) NextFromState(state string) string {
next, _, err := oidcclient.ParseState(state)
if err != nil {
return ""
}
return next
}

// IsSupportPortalNext reports whether the OIDC round-trip started from the
// customer support portal rather than the staff dashboard. The portal always
// targets /support/* paths (see getSupportLoginDestination), so the signed
// state's next path identifies the entry surface without extra parameters.
// Portal-origin logins provision customer-type users without staff roles;
// only the staff surface (/dashboard/login) grants staff access.
func isSupportPortalNext(next string) bool {
// only the staff surface (/dashboard/login) grants staff access. Handlers
// also use it to route failed round-trips back to the originating surface.
func IsSupportPortalNext(next string) bool {
next = strings.TrimSpace(next)
return next == "/support" || strings.HasPrefix(next, "/support/")
}
Expand Down
2 changes: 1 addition & 1 deletion internal/services/oidc_login_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ func TestIsSupportPortalNext(t *testing.T) {
}

for _, tc := range cases {
if got := isSupportPortalNext(tc.next); got != tc.expected {
if got := IsSupportPortalNext(tc.next); got != tc.expected {
t.Fatalf("isSupportPortalNext(%q) = %v, want %v", tc.next, got, tc.expected)
}
}
Expand Down
18 changes: 13 additions & 5 deletions internal/services/user_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package services

import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
Expand Down Expand Up @@ -220,22 +221,22 @@ func (s *userService) UpdateStatus(id int64, status int, operator *dto.AuthPrinc
return nil
}

func (s *userService) ResetPassword(userID int64, operator *dto.AuthPrincipal) (string, error) {
func (s *userService) ResetPassword(userID int64, operator *dto.AuthPrincipal, authCfg config.AuthConfig) (string, error) {
password, err := utils.GenerateRandomPassword(12)
if err != nil {
return "", err
}
if err = s.changePassword(userID, password, operator); err != nil {
if err = s.changePassword(userID, password, operator, authCfg); err != nil {
return "", err
}
return password, nil
}

func (s *userService) ChangeOwnPassword(password string, operator *dto.AuthPrincipal) error {
func (s *userService) ChangeOwnPassword(password string, operator *dto.AuthPrincipal, authCfg config.AuthConfig) error {
if operator == nil || operator.UserID <= 0 {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
return s.changePassword(operator.UserID, password, operator)
return s.changePassword(operator.UserID, password, operator, authCfg)
}

func (s *userService) AssignRoles(userID int64, roleIDs []int64, operator *dto.AuthPrincipal) error {
Expand Down Expand Up @@ -282,11 +283,18 @@ func (s *userService) replaceUserRolesDB(db *gorm.DB, userID int64, roleIDs []in
return nil
}

func (s *userService) changePassword(userID int64, password string, operator *dto.AuthPrincipal) error {
func (s *userService) changePassword(userID int64, password string, operator *dto.AuthPrincipal, authCfg config.AuthConfig) error {
user := s.Get(userID)
if user == nil || user.DeletedAt != nil {
return errorsx.InvalidParamI18n("error.e0255")
}
// SSO-only invariant: a local password is only ever usable by break-glass
// principals, so never set one for anyone else. Otherwise password resets
// would plant dormant credentials that come alive the moment password
// login is re-enabled.
if !authCfg.IsPasswordLoginEnabled() && !isBreakGlassUser(authCfg, user) {
return errorsx.ForbiddenI18n("error.auth.passwordChangeDisabled")
}
if operator != nil && operator.UserID != userID && !slices.Contains(operator.Roles, string(constants.RoleCodeSuperAdmin)) {
var superAdminCount int64
if err := sqls.DB().Model(&models.UserRole{}).
Expand Down
Loading
Loading