修复用户安全信息弹窗与会话校验
This commit is contained in:
parent
31ee54942a
commit
306af4b7b0
@ -728,6 +728,77 @@ func BindExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Re
|
|||||||
response.NewSuccessResponse(map[string]any{"id": item.ID, "challenge_id": challenge.ID, "status": item.Status, "email": maskEmail(input.Email), "expires_at": challenge.ExpiresAt.UTC()}).FPrint(w)
|
response.NewSuccessResponse(map[string]any{"id": item.ID, "challenge_id": challenge.ID, "status": item.Status, "email": maskEmail(input.Email), "expires_at": challenge.ExpiresAt.UTC()}).FPrint(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChangeOwnExternalEmail 发送当前用户外部邮箱变更验证码,旧邮箱会保留到验证成功。
|
||||||
|
func ChangeOwnExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||||
|
runtime := currentSecurityRuntime()
|
||||||
|
if ctx == nil || ctx.UserID <= 0 || runtime.external == nil || runtime.verification == nil {
|
||||||
|
writeSecurityError(w, response.NeedLogin, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ctx.RecentAuth {
|
||||||
|
writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "此操作需要最近十分钟内完成登录,请确认当前密码", string(access.CodeRecentAuthRequired))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input externalEmailRequest
|
||||||
|
if err := decodeJSONRequest(w, req, &input); err != nil {
|
||||||
|
writeSecurityError(w, response.ParamsError, "请求无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
normalizedEmail, err := externalemail.NormalizeEmail(input.Email)
|
||||||
|
if err != nil {
|
||||||
|
writeSecurityError(w, response.ParamsError, "请输入有效的外部邮箱地址")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
owned, err := currentUserPrimaryEmail(ctx.UserID, normalizedEmail)
|
||||||
|
if err != nil {
|
||||||
|
log.WithContext(ctx).WithError(err).Error("check current user primary email failed")
|
||||||
|
writeSecurityError(w, response.ServerError, "外部邮箱变更失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if owned {
|
||||||
|
writeSecurityError(w, response.ParamsError, "不能将当前邮箱绑定为外部安全邮箱")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emailType := strings.TrimSpace(input.Type)
|
||||||
|
if emailType == "" {
|
||||||
|
emailType = models.ExternalEmailTypePrimary
|
||||||
|
}
|
||||||
|
item, err := runtime.external.BeginReplacement(externalemail.BindRequest{
|
||||||
|
UserID: ctx.UserID,
|
||||||
|
Email: normalizedEmail,
|
||||||
|
Type: emailType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, externalemail.ErrBindingExists) {
|
||||||
|
writeSecurityError(w, response.ParamsError, "该外部邮箱已经绑定或正在被其他账号使用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.WithContext(ctx).WithError(err).Error("begin own external email replacement failed")
|
||||||
|
writeSecurityError(w, response.ParamsError, "外部邮箱变更失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if runtime.verification.Store != nil {
|
||||||
|
runtime.verification.Store.InvalidateResource(item.ID, verification.PurposeExternalEmailChange)
|
||||||
|
runtime.verification.Store.InvalidateResource(item.ID, verification.PurposeExternalEmailBind)
|
||||||
|
}
|
||||||
|
challenge, err := runtime.verification.Issue(verification.IssueRequest{
|
||||||
|
Purpose: verification.PurposeExternalEmailChange,
|
||||||
|
UserID: ctx.UserID,
|
||||||
|
Account: ctx.UserAccount,
|
||||||
|
Email: normalizedEmail,
|
||||||
|
ResourceID: item.ID,
|
||||||
|
}, remoteIP(req))
|
||||||
|
if err != nil {
|
||||||
|
log.WithContext(ctx).WithError(err).Warn("issue own external email verification failed")
|
||||||
|
writeSecurityError(w, response.ParamsError, externalEmailVerificationIssueMessage(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NewSuccessResponse(map[string]any{
|
||||||
|
"binding_id": item.ID, "challenge_id": challenge.ID,
|
||||||
|
"email": maskEmail(normalizedEmail), "expires_at": challenge.ExpiresAt.UTC(),
|
||||||
|
}).FPrint(w)
|
||||||
|
}
|
||||||
|
|
||||||
func currentUserPrimaryEmail(userID int, email string) (bool, error) {
|
func currentUserPrimaryEmail(userID int, email string) (bool, error) {
|
||||||
if db.Instance == nil || userID <= 0 {
|
if db.Instance == nil || userID <= 0 {
|
||||||
return false, errors.New("database is unavailable")
|
return false, errors.New("database is unavailable")
|
||||||
@ -758,6 +829,44 @@ func VerifyExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.
|
|||||||
response.NewSuccessResponse(map[string]any{"verified": true}).FPrint(w)
|
response.NewSuccessResponse(map[string]any{"verified": true}).FPrint(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VerifyOwnExternalEmail 验证成功后原子替换当前用户同用途的旧邮箱。
|
||||||
|
func VerifyOwnExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||||
|
runtime := currentSecurityRuntime()
|
||||||
|
if ctx == nil || ctx.UserID <= 0 || runtime.external == nil || runtime.verification == nil {
|
||||||
|
writeSecurityError(w, response.NeedLogin, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input verificationCheckRequest
|
||||||
|
if err := decodeJSONRequest(w, req, &input); err != nil || input.BindingID <= 0 {
|
||||||
|
writeSecurityError(w, response.ParamsError, "验证请求无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
normalizedEmail, err := externalemail.NormalizeEmail(input.Email)
|
||||||
|
if err != nil {
|
||||||
|
writeSecurityError(w, response.ParamsError, "验证失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := runtime.verification.VerifyBound(
|
||||||
|
input.ID,
|
||||||
|
ctx.UserAccount,
|
||||||
|
normalizedEmail,
|
||||||
|
input.Code,
|
||||||
|
verification.PurposeExternalEmailChange,
|
||||||
|
input.BindingID,
|
||||||
|
remoteIP(req),
|
||||||
|
); err != nil {
|
||||||
|
writeSecurityError(w, response.ParamsError, externalEmailVerificationCheckMessage(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := runtime.external.VerifyReplacement(ctx.UserID, input.BindingID); err != nil {
|
||||||
|
log.WithContext(ctx).WithError(err).Error("verify own external email replacement failed")
|
||||||
|
writeSecurityError(w, response.ParamsError, "外部邮箱替换失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
InvalidateSecurityRuntimeUser(ctx.UserID, ctx.UserAccount)
|
||||||
|
response.NewSuccessResponse(map[string]any{"verified": true}).FPrint(w)
|
||||||
|
}
|
||||||
|
|
||||||
func DisableExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
func DisableExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||||
runtime := currentSecurityRuntime()
|
runtime := currentSecurityRuntime()
|
||||||
if ctx == nil || ctx.UserID <= 0 || runtime.external == nil {
|
if ctx == nil || ctx.UserID <= 0 || runtime.external == nil {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
"git.pcux.cn/xuans/Smail/services/auth"
|
"git.pcux.cn/xuans/Smail/services/auth"
|
||||||
"git.pcux.cn/xuans/Smail/utils/array"
|
"git.pcux.cn/xuans/Smail/utils/array"
|
||||||
"git.pcux.cn/xuans/Smail/utils/context"
|
"git.pcux.cn/xuans/Smail/utils/context"
|
||||||
|
"git.pcux.cn/xuans/Smail/utils/csrf"
|
||||||
"git.pcux.cn/xuans/Smail/utils/password"
|
"git.pcux.cn/xuans/Smail/utils/password"
|
||||||
"git.pcux.cn/xuans/Smail/utils/validation"
|
"git.pcux.cn/xuans/Smail/utils/validation"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@ -204,6 +205,11 @@ func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||||
|
if _, err := csrf.Ensure(w, req, !config.DevelopmentMode); err != nil {
|
||||||
|
log.WithContext(ctx).WithError(err).Error("restore csrf token failed")
|
||||||
|
response.NewErrorResponse(response.ServerError, "登录状态恢复失败", "").FPrint(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
mailDomains := config.CurrentMailDomains()
|
mailDomains := config.CurrentMailDomains()
|
||||||
if mailDomains.Primary == "" {
|
if mailDomains.Primary == "" {
|
||||||
response.NewErrorResponse(response.ServerError, "服务配置不可用", "").FPrint(w)
|
response.NewErrorResponse(response.ServerError, "服务配置不可用", "").FPrint(w)
|
||||||
@ -212,6 +218,7 @@ func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
|||||||
domains := array.Difference(mailDomains.Accepted, []string{mailDomains.Primary})
|
domains := array.Difference(mailDomains.Accepted, []string{mailDomains.Primary})
|
||||||
domains = append([]string{mailDomains.Primary}, domains...)
|
domains = append([]string{mailDomains.Primary}, domains...)
|
||||||
result := map[string]any{
|
result := map[string]any{
|
||||||
|
"id": ctx.UserID,
|
||||||
"account": ctx.UserAccount,
|
"account": ctx.UserAccount,
|
||||||
"name": ctx.UserName,
|
"name": ctx.UserName,
|
||||||
"role": ctx.EffectiveRole(),
|
"role": ctx.EffectiveRole(),
|
||||||
|
|||||||
@ -41,6 +41,8 @@ func registerV1Routes(mux *http.ServeMux) {
|
|||||||
registerV1MethodRoute(mux, "/api/v1/security/external-email/list", "/api/security/external-email/list", http.MethodGet, 0, controllers.ListExternalEmails)
|
registerV1MethodRoute(mux, "/api/v1/security/external-email/list", "/api/security/external-email/list", http.MethodGet, 0, controllers.ListExternalEmails)
|
||||||
registerV1MethodRoute(mux, "/api/v1/security/external-email/bind", "/api/security/external-email/bind", http.MethodPost, 16<<10, controllers.BindExternalEmail)
|
registerV1MethodRoute(mux, "/api/v1/security/external-email/bind", "/api/security/external-email/bind", http.MethodPost, 16<<10, controllers.BindExternalEmail)
|
||||||
registerV1MethodRoute(mux, "/api/v1/security/external-email/verify", "/api/security/external-email/verify", http.MethodPost, 16<<10, controllers.VerifyExternalEmail)
|
registerV1MethodRoute(mux, "/api/v1/security/external-email/verify", "/api/security/external-email/verify", http.MethodPost, 16<<10, controllers.VerifyExternalEmail)
|
||||||
|
registerV1MethodRoute(mux, "/api/v1/security/external-email/change", "/api/security/external-email/change", http.MethodPost, 16<<10, controllers.ChangeOwnExternalEmail)
|
||||||
|
registerV1MethodRoute(mux, "/api/v1/security/external-email/change/verify", "/api/security/external-email/change/verify", http.MethodPost, 16<<10, controllers.VerifyOwnExternalEmail)
|
||||||
registerV1MethodRoute(mux, "/api/v1/security/external-email/disable", "/api/security/external-email/disable", http.MethodPost, 16<<10, controllers.DisableExternalEmail)
|
registerV1MethodRoute(mux, "/api/v1/security/external-email/disable", "/api/security/external-email/disable", http.MethodPost, 16<<10, controllers.DisableExternalEmail)
|
||||||
registerV1MethodRoute(mux, "/api/v1/logout", "/api/logout", http.MethodPost, 4<<10, controllers.Logout)
|
registerV1MethodRoute(mux, "/api/v1/logout", "/api/logout", http.MethodPost, 4<<10, controllers.Logout)
|
||||||
registerV1MethodRoute(mux, "/api/v1/system/restart", "/api/v1/system/restart", http.MethodPost, 4<<10, controllers.RestartSmail)
|
registerV1MethodRoute(mux, "/api/v1/system/restart", "/api/v1/system/restart", http.MethodPost, 4<<10, controllers.RestartSmail)
|
||||||
|
|||||||
@ -192,6 +192,8 @@ func TestFrontendV1RoutesAreRegistered(t *testing.T) {
|
|||||||
"/api/v1/security/credentials/protocols/generate",
|
"/api/v1/security/credentials/protocols/generate",
|
||||||
"/api/v1/security/credentials/protocols/revoke",
|
"/api/v1/security/credentials/protocols/revoke",
|
||||||
"/api/v1/security/external-email/bind",
|
"/api/v1/security/external-email/bind",
|
||||||
|
"/api/v1/security/external-email/change",
|
||||||
|
"/api/v1/security/external-email/change/verify",
|
||||||
"/api/v1/security/external-email/disable",
|
"/api/v1/security/external-email/disable",
|
||||||
"/api/v1/security/external-email/list",
|
"/api/v1/security/external-email/list",
|
||||||
"/api/v1/security/external-email/verify",
|
"/api/v1/security/external-email/verify",
|
||||||
|
|||||||
@ -74,6 +74,8 @@ func router(mux *http.ServeMux) error {
|
|||||||
mux.HandleFunc("/api/security/external-email/list", onlyMethod(http.MethodGet, contextIterceptor(controllers.ListExternalEmails)))
|
mux.HandleFunc("/api/security/external-email/list", onlyMethod(http.MethodGet, contextIterceptor(controllers.ListExternalEmails)))
|
||||||
mux.HandleFunc("/api/security/external-email/bind", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.BindExternalEmail))))
|
mux.HandleFunc("/api/security/external-email/bind", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.BindExternalEmail))))
|
||||||
mux.HandleFunc("/api/security/external-email/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyExternalEmail))))
|
mux.HandleFunc("/api/security/external-email/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyExternalEmail))))
|
||||||
|
mux.HandleFunc("/api/security/external-email/change", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.ChangeOwnExternalEmail))))
|
||||||
|
mux.HandleFunc("/api/security/external-email/change/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyOwnExternalEmail))))
|
||||||
mux.HandleFunc("/api/security/external-email/disable", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.DisableExternalEmail))))
|
mux.HandleFunc("/api/security/external-email/disable", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.DisableExternalEmail))))
|
||||||
mux.HandleFunc("/api/logout", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.Logout))))
|
mux.HandleFunc("/api/logout", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.Logout))))
|
||||||
mux.HandleFunc("/api/group", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetUserGroup)))
|
mux.HandleFunc("/api/group", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetUserGroup)))
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.pcux.cn/xuans/Smail/session"
|
"git.pcux.cn/xuans/Smail/session"
|
||||||
@ -29,6 +30,20 @@ func Issue(w http.ResponseWriter, r *http.Request, secure bool) (string, error)
|
|||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure 为已认证会话恢复双提交 CSRF Cookie。
|
||||||
|
// Cookie 缺失或与服务端会话不一致时轮换令牌,避免继续信任过期的浏览器状态。
|
||||||
|
func Ensure(w http.ResponseWriter, r *http.Request, secure bool) (string, error) {
|
||||||
|
if session.Instance == nil {
|
||||||
|
return "", errors.New("session manager is unavailable")
|
||||||
|
}
|
||||||
|
stored := session.Instance.GetString(r.Context(), SessionKey)
|
||||||
|
cookie, err := r.Cookie(CookieName)
|
||||||
|
if err == nil && stored != "" && constantTimeEqual(stored, cookie.Value) {
|
||||||
|
return stored, nil
|
||||||
|
}
|
||||||
|
return Issue(w, r, secure)
|
||||||
|
}
|
||||||
|
|
||||||
func Clear(w http.ResponseWriter, r *http.Request, secure bool) {
|
func Clear(w http.ResponseWriter, r *http.Request, secure bool) {
|
||||||
if session.Instance != nil {
|
if session.Instance != nil {
|
||||||
session.Instance.Remove(r.Context(), SessionKey)
|
session.Instance.Remove(r.Context(), SessionKey)
|
||||||
@ -49,9 +64,12 @@ func Valid(r *http.Request) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
stored := session.Instance.GetString(r.Context(), SessionKey)
|
stored := session.Instance.GetString(r.Context(), SessionKey)
|
||||||
if stored == "" || len(stored) != len(header) || len(cookie.Value) != len(header) {
|
if stored == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return subtle.ConstantTimeCompare([]byte(stored), []byte(header)) == 1 &&
|
return constantTimeEqual(stored, header) && constantTimeEqual(cookie.Value, header)
|
||||||
subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(header)) == 1
|
}
|
||||||
|
|
||||||
|
func constantTimeEqual(left, right string) bool {
|
||||||
|
return len(left) == len(right) && subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,3 +76,73 @@ func TestClearInvalidatesExistingToken(t *testing.T) {
|
|||||||
t.Fatalf("unexpected CSRF clear cookie: %+v", cookies)
|
t.Fatalf("unexpected CSRF clear cookie: %+v", cookies)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnsureRestoresMissingCookieWithRotatedToken(t *testing.T) {
|
||||||
|
old := session.Instance
|
||||||
|
manager := scs.New()
|
||||||
|
session.Instance = manager
|
||||||
|
t.Cleanup(func() { session.Instance = old })
|
||||||
|
|
||||||
|
request := testRequestWithToken(t, manager, "old-token", "", "")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
token, err := Ensure(recorder, request, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if token == "" || token == "old-token" {
|
||||||
|
t.Fatal("missing cookie did not rotate the CSRF token")
|
||||||
|
}
|
||||||
|
if stored := manager.GetString(request.Context(), SessionKey); stored != token {
|
||||||
|
t.Fatalf("rotated token was not stored in session: %q", stored)
|
||||||
|
}
|
||||||
|
cookies := recorder.Result().Cookies()
|
||||||
|
if len(cookies) != 1 {
|
||||||
|
t.Fatalf("unexpected CSRF cookies: %+v", cookies)
|
||||||
|
}
|
||||||
|
cookie := cookies[0]
|
||||||
|
if cookie.Name != CookieName || cookie.Value != token || cookie.Path != "/" ||
|
||||||
|
cookie.MaxAge != 86400 || !cookie.Secure || cookie.HttpOnly ||
|
||||||
|
cookie.SameSite != http.SameSiteStrictMode {
|
||||||
|
t.Fatalf("unexpected restored CSRF cookie: %+v", cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureKeepsMatchingTokenWithoutReissuingCookie(t *testing.T) {
|
||||||
|
old := session.Instance
|
||||||
|
manager := scs.New()
|
||||||
|
session.Instance = manager
|
||||||
|
t.Cleanup(func() { session.Instance = old })
|
||||||
|
|
||||||
|
request := testRequestWithToken(t, manager, "current-token", "", "current-token")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
token, err := Ensure(recorder, request, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if token != "current-token" {
|
||||||
|
t.Fatalf("matching token was unexpectedly rotated: %q", token)
|
||||||
|
}
|
||||||
|
if cookies := recorder.Result().Cookies(); len(cookies) != 0 {
|
||||||
|
t.Fatalf("matching token unexpectedly reissued cookies: %+v", cookies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureRotatesMismatchedCookie(t *testing.T) {
|
||||||
|
old := session.Instance
|
||||||
|
manager := scs.New()
|
||||||
|
session.Instance = manager
|
||||||
|
t.Cleanup(func() { session.Instance = old })
|
||||||
|
|
||||||
|
request := testRequestWithToken(t, manager, "stored-token", "", "stale-token")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
token, err := Ensure(recorder, request, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if token == "" || token == "stored-token" || token == "stale-token" {
|
||||||
|
t.Fatalf("mismatched cookie did not rotate token: %q", token)
|
||||||
|
}
|
||||||
|
if cookies := recorder.Result().Cookies(); len(cookies) != 1 || cookies[0].Secure {
|
||||||
|
t.Fatalf("unexpected development CSRF cookie: %+v", cookies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
:close-on-press-escape="!busy"
|
:close-on-press-escape="!busy"
|
||||||
:show-close="!busy"
|
:show-close="!busy"
|
||||||
|
append-to-body
|
||||||
|
destroy-on-close
|
||||||
class="recovery-code-dialog"
|
class="recovery-code-dialog"
|
||||||
@update:model-value="value => { if (!value) close() }"
|
@update:model-value="value => { if (!value) close() }"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -15,6 +15,10 @@ export const userExternalEmailDialogProps = {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
selfManaged: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const verificationCodeLength = 8
|
const verificationCodeLength = 8
|
||||||
@ -30,6 +34,21 @@ function assertSuccess(response, fallback) {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function externalEmailEndpoints(selfManaged) {
|
||||||
|
if (selfManaged) {
|
||||||
|
return {
|
||||||
|
list: '/api/v1/security/external-email/list',
|
||||||
|
change: '/api/v1/security/external-email/change',
|
||||||
|
verify: '/api/v1/security/external-email/change/verify',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
list: '/api/v1/user/security/external-email/list',
|
||||||
|
change: '/api/v1/user/security/external-email/change',
|
||||||
|
verify: '/api/v1/user/security/external-email/verify',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useUserExternalEmailDialog(props, emit) {
|
export function useUserExternalEmailDialog(props, emit) {
|
||||||
const form = reactive({ email: '', type: 'primary', code: '' })
|
const form = reactive({ email: '', type: 'primary', code: '' })
|
||||||
const challenge = reactive({ id: '', bindingId: 0, maskedEmail: '', expiresAt: '' })
|
const challenge = reactive({ id: '', bindingId: 0, maskedEmail: '', expiresAt: '' })
|
||||||
@ -47,6 +66,7 @@ export function useUserExternalEmailDialog(props, emit) {
|
|||||||
set: value => emit('update:modelValue', value),
|
set: value => emit('update:modelValue', value),
|
||||||
})
|
})
|
||||||
const busy = computed(() => sending.value || verifying.value)
|
const busy = computed(() => sending.value || verifying.value)
|
||||||
|
const endpoints = computed(() => externalEmailEndpoints(props.selfManaged))
|
||||||
const hasChallenge = computed(() => Boolean(challenge.id && challenge.bindingId))
|
const hasChallenge = computed(() => Boolean(challenge.id && challenge.bindingId))
|
||||||
const remainingSeconds = computed(() => {
|
const remainingSeconds = computed(() => {
|
||||||
if (!challenge.expiresAt) return 0
|
if (!challenge.expiresAt) return 0
|
||||||
@ -93,12 +113,13 @@ export function useUserExternalEmailDialog(props, emit) {
|
|||||||
if (!props.user?.ID) return false
|
if (!props.user?.ID) return false
|
||||||
try {
|
try {
|
||||||
const response = await requestWithRecentAuthentication(async () => {
|
const response = await requestWithRecentAuthentication(async () => {
|
||||||
const result = await http.get('/api/v1/user/security/external-email/list', {
|
const config = props.selfManaged ? undefined : { params: { id: props.user.ID } }
|
||||||
params: { id: props.user.ID },
|
const result = await http.get(endpoints.value.list, config)
|
||||||
})
|
|
||||||
return assertSuccess(result, '读取外部邮箱失败')
|
return assertSuccess(result, '读取外部邮箱失败')
|
||||||
})
|
})
|
||||||
const items = Array.isArray(response.data?.items) ? response.data.items : []
|
const items = props.selfManaged
|
||||||
|
? (Array.isArray(response.data) ? response.data : [])
|
||||||
|
: (Array.isArray(response.data?.items) ? response.data.items : [])
|
||||||
const primary = items.find(item => item.type === 'primary') || items[0]
|
const primary = items.find(item => item.type === 'primary') || items[0]
|
||||||
currentEmail.value = primary?.email || ''
|
currentEmail.value = primary?.email || ''
|
||||||
if (primary?.type) form.type = primary.type
|
if (primary?.type) form.type = primary.type
|
||||||
@ -122,11 +143,12 @@ export function useUserExternalEmailDialog(props, emit) {
|
|||||||
clearMessages()
|
clearMessages()
|
||||||
try {
|
try {
|
||||||
const response = await requestWithRecentAuthentication(async () => {
|
const response = await requestWithRecentAuthentication(async () => {
|
||||||
const result = await http.post('/api/v1/user/security/external-email/change', {
|
const payload = {
|
||||||
user_id: props.user.ID,
|
|
||||||
email,
|
email,
|
||||||
type: form.type,
|
type: form.type,
|
||||||
})
|
}
|
||||||
|
if (!props.selfManaged) payload.user_id = props.user.ID
|
||||||
|
const result = await http.post(endpoints.value.change, payload)
|
||||||
return assertSuccess(result, '验证码发送失败')
|
return assertSuccess(result, '验证码发送失败')
|
||||||
})
|
})
|
||||||
if (!response.data?.challenge_id || !response.data?.binding_id) {
|
if (!response.data?.challenge_id || !response.data?.binding_id) {
|
||||||
@ -169,13 +191,18 @@ export function useUserExternalEmailDialog(props, emit) {
|
|||||||
clearMessages()
|
clearMessages()
|
||||||
try {
|
try {
|
||||||
const response = await requestWithRecentAuthentication(async () => {
|
const response = await requestWithRecentAuthentication(async () => {
|
||||||
const result = await http.post('/api/v1/user/security/external-email/verify', {
|
const payload = {
|
||||||
user_id: props.user.ID,
|
|
||||||
challenge_id: challenge.id,
|
|
||||||
binding_id: challenge.bindingId,
|
binding_id: challenge.bindingId,
|
||||||
email: form.email,
|
email: form.email,
|
||||||
code,
|
code,
|
||||||
})
|
}
|
||||||
|
if (props.selfManaged) {
|
||||||
|
payload.id = challenge.id
|
||||||
|
} else {
|
||||||
|
payload.user_id = props.user.ID
|
||||||
|
payload.challenge_id = challenge.id
|
||||||
|
}
|
||||||
|
const result = await http.post(endpoints.value.verify, payload)
|
||||||
return assertSuccess(result, '外部邮箱验证失败')
|
return assertSuccess(result, '外部邮箱验证失败')
|
||||||
})
|
})
|
||||||
emit('changed')
|
emit('changed')
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog
|
<el-dialog
|
||||||
:model-value="visible"
|
:model-value="visible"
|
||||||
title="编辑外部邮箱"
|
:title="selfManaged ? '编辑我的外部邮箱' : '编辑外部邮箱'"
|
||||||
width="min(520px, calc(100vw - 32px))"
|
width="min(520px, calc(100vw - 32px))"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
:close-on-press-escape="!busy"
|
:close-on-press-escape="!busy"
|
||||||
:show-close="!busy"
|
:show-close="!busy"
|
||||||
|
append-to-body
|
||||||
|
destroy-on-close
|
||||||
class="user-external-email-dialog"
|
class="user-external-email-dialog"
|
||||||
@update:model-value="value => { if (!value) close() }"
|
@update:model-value="value => { if (!value) close() }"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import {reactive, ref} from 'vue'
|
import {computed, reactive, ref} from 'vue'
|
||||||
|
|
||||||
import lang from '../../../i18n/i18n';
|
import lang from '../../../i18n/i18n';
|
||||||
|
|
||||||
@ -8,6 +8,8 @@ import {ElNotification} from "element-plus";
|
|||||||
|
|
||||||
import {Plus} from "@element-plus/icons-vue";
|
import {Plus} from "@element-plus/icons-vue";
|
||||||
|
|
||||||
|
import {useGlobalStatusStore} from "@/stores/useGlobalStatusStore";
|
||||||
|
|
||||||
const roleOptions = {
|
const roleOptions = {
|
||||||
super_admin: { label: '超级管理员', type: 'danger' },
|
super_admin: { label: '超级管理员', type: 'danger' },
|
||||||
admin: { label: '管理员', type: 'primary' },
|
admin: { label: '管理员', type: 'primary' },
|
||||||
@ -39,7 +41,23 @@ export function accountStatusMeta(disabled) {
|
|||||||
: { label: '正常', type: 'success' }
|
: { label: '正常', type: 'success' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSameUser(selectedUser, currentUser) {
|
||||||
|
const selectedID = Number(selectedUser?.ID || 0)
|
||||||
|
const currentID = Number(currentUser?.id || 0)
|
||||||
|
if (selectedID > 0 && currentID > 0) {
|
||||||
|
return selectedID === currentID
|
||||||
|
}
|
||||||
|
return Boolean(selectedUser?.Account && currentUser?.account)
|
||||||
|
&& selectedUser.Account === currentUser.account
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canManageRecoveryCode(user) {
|
||||||
|
return Boolean(user?.ID) && user.Role !== 'super_admin'
|
||||||
|
}
|
||||||
|
|
||||||
export function usePage() {
|
export function usePage() {
|
||||||
|
const globalStatus = useGlobalStatusStore()
|
||||||
|
|
||||||
const userList = reactive([])
|
const userList = reactive([])
|
||||||
|
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
@ -55,6 +73,10 @@ export function usePage() {
|
|||||||
const recoveryCodeDialogVisible = ref(false)
|
const recoveryCodeDialogVisible = ref(false)
|
||||||
|
|
||||||
const externalEmailDialogVisible = ref(false)
|
const externalEmailDialogVisible = ref(false)
|
||||||
|
|
||||||
|
const editingCurrentUser = computed(() => isSameUser(selectedUser.value, globalStatus.userInfos))
|
||||||
|
|
||||||
|
const recoveryCodeWebManaged = computed(() => canManageRecoveryCode(selectedUser.value))
|
||||||
|
|
||||||
const editUserInfo = reactive({
|
const editUserInfo = reactive({
|
||||||
"account": "",
|
"account": "",
|
||||||
@ -101,7 +123,7 @@ export function usePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openRecoveryCodeDialog = function () {
|
const openRecoveryCodeDialog = function () {
|
||||||
if (!selectedUser.value?.ID) return
|
if (!recoveryCodeWebManaged.value) return
|
||||||
recoveryCodeDialogVisible.value = true
|
recoveryCodeDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,6 +196,8 @@ export function usePage() {
|
|||||||
editModel,
|
editModel,
|
||||||
editUserInfo,
|
editUserInfo,
|
||||||
selectedUser,
|
selectedUser,
|
||||||
|
editingCurrentUser,
|
||||||
|
recoveryCodeWebManaged,
|
||||||
recoveryCodeDialogVisible,
|
recoveryCodeDialogVisible,
|
||||||
externalEmailDialogVisible,
|
externalEmailDialogVisible,
|
||||||
openRecoveryCodeDialog,
|
openRecoveryCodeDialog,
|
||||||
|
|||||||
@ -115,9 +115,17 @@
|
|||||||
<div class="security-edit-item">
|
<div class="security-edit-item">
|
||||||
<div>
|
<div>
|
||||||
<strong>安全码</strong>
|
<strong>安全码</strong>
|
||||||
<span>{{ recoveryCodeMeta(selectedUser?.RecoveryCodeConfigured).label }}</span>
|
<span v-if="recoveryCodeWebManaged">
|
||||||
|
{{ recoveryCodeMeta(selectedUser?.RecoveryCodeConfigured).label }}
|
||||||
|
</span>
|
||||||
|
<span v-else>部署端管理</span>
|
||||||
</div>
|
</div>
|
||||||
<el-button @click="openRecoveryCodeDialog">管理安全码</el-button>
|
<el-button
|
||||||
|
:disabled="!recoveryCodeWebManaged"
|
||||||
|
@click="openRecoveryCodeDialog"
|
||||||
|
>
|
||||||
|
{{ recoveryCodeWebManaged ? '管理安全码' : '仅支持本机管理' }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="security-edit-item">
|
<div class="security-edit-item">
|
||||||
<div>
|
<div>
|
||||||
@ -164,6 +172,7 @@
|
|||||||
<UserExternalEmailDialog
|
<UserExternalEmailDialog
|
||||||
v-model="externalEmailDialogVisible"
|
v-model="externalEmailDialogVisible"
|
||||||
:user="selectedUser"
|
:user="selectedUser"
|
||||||
|
:self-managed="editingCurrentUser"
|
||||||
@changed="handleSecurityChanged"
|
@changed="handleSecurityChanged"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -188,6 +197,8 @@ const {
|
|||||||
editModel,
|
editModel,
|
||||||
editUserInfo,
|
editUserInfo,
|
||||||
selectedUser,
|
selectedUser,
|
||||||
|
editingCurrentUser,
|
||||||
|
recoveryCodeWebManaged,
|
||||||
recoveryCodeDialogVisible,
|
recoveryCodeDialogVisible,
|
||||||
externalEmailDialogVisible,
|
externalEmailDialogVisible,
|
||||||
openRecoveryCodeDialog,
|
openRecoveryCodeDialog,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user