feat(security): 支持按客户端独立设置专属授权码并重构紧凑单屏无滚动条布局

This commit is contained in:
smail 2026-09-13 21:50:19 +08:00
parent 744e2584ef
commit 102ddca245
13 changed files with 1165 additions and 376 deletions

View File

@ -0,0 +1,111 @@
package controllers
import (
"net/http"
"strings"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/utils/context"
)
// ListClientCredentials 返回当前用户的客户端授权码列表
func ListClientCredentials(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
service, ok := requireProtocolCredentialContext(ctx, w)
if !ok {
return
}
items, err := service.ListClientCredentials(ctx.UserID)
if err != nil {
writeSecurityError(w, response.ParamsError, "读取客户端授权码失败")
return
}
response.NewSuccessResponse(items).FPrint(w)
}
// CreateClientCredential 为当前用户创建客户端授权码
func CreateClientCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
service, ok := requireProtocolCredentialContext(ctx, w)
if !ok {
return
}
var input struct {
ClientName string `json:"client_name"`
}
if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.ClientName) == "" {
writeSecurityError(w, response.ParamsError, "请输入客户端名称")
return
}
code, item, err := service.CreateClientCredential(ctx.UserID, input.ClientName)
if err != nil {
writeSecurityError(w, response.ParamsError, err.Error())
return
}
response.NewSuccessResponse(map[string]any{"code": code, "client": item}).FPrint(w)
}
// RegenerateClientCredential 重新生成客户端专属授权码
func RegenerateClientCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
service, ok := requireProtocolCredentialContext(ctx, w)
if !ok {
return
}
var input struct {
ClientID int `json:"client_id"`
}
if err := decodeJSONRequest(w, req, &input); err != nil || input.ClientID <= 0 {
writeSecurityError(w, response.ParamsError, "客户端标识无效")
return
}
code, err := service.RegenerateClientCredential(ctx.UserID, input.ClientID)
if err != nil {
writeSecurityError(w, response.ParamsError, err.Error())
return
}
response.NewSuccessResponse(map[string]any{"code": code, "client_id": input.ClientID}).FPrint(w)
}
// RevokeClientCredential 撤销客户端授权码
func RevokeClientCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
service, ok := requireProtocolCredentialContext(ctx, w)
if !ok {
return
}
var input struct {
ClientID int `json:"client_id"`
}
if err := decodeJSONRequest(w, req, &input); err != nil || input.ClientID <= 0 {
writeSecurityError(w, response.ParamsError, "客户端标识无效")
return
}
if err := service.RevokeClientCredential(ctx.UserID, input.ClientID); err != nil {
writeSecurityError(w, response.ParamsError, err.Error())
return
}
response.NewSuccessResponse(map[string]any{"client_id": input.ClientID, "revoked": true}).FPrint(w)
}
// RevealClientCredentials 在安全身份验证成功后解密返回所有客户端授权码
func RevealClientCredentials(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
service, ok := requireProtocolCredentialContext(ctx, w)
if !ok {
return
}
var input struct {
Password string `json:"password"`
}
if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.Password) == "" {
writeSecurityError(w, response.ParamsError, "请输入当前账号密码进行安全验证")
return
}
if _, err := auth.AuthenticateUserByID(ctx.UserID, input.Password); err != nil {
writeSecurityError(w, response.ParamsError, "密码验证失败")
return
}
codes, err := service.RevealClientCredentials(ctx.UserID)
if err != nil {
writeSecurityError(w, response.ServerError, "解密授权码失败")
return
}
response.NewSuccessResponse(codes).FPrint(w)
}

View File

@ -0,0 +1,19 @@
package migration
func clientCredentialStatements() map[Dialect][]string {
return map[Dialect][]string{
DialectSQLite: {
`CREATE TABLE IF NOT EXISTS "client_credential" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL, "client_name" TEXT NOT NULL, "code_hash" TEXT NOT NULL DEFAULT '', "code_ciphertext" TEXT NULL, "code_version" INTEGER NOT NULL DEFAULT 1, "created_at" DATETIME NOT NULL, "updated_at" DATETIME NOT NULL, "last_used_at" DATETIME NULL, "revoked_at" DATETIME NULL, UNIQUE("user_id", "client_name"))`,
`CREATE INDEX IF NOT EXISTS "idx_client_credential_user_id" ON "client_credential"("user_id")`,
`CREATE INDEX IF NOT EXISTS "idx_client_credential_revoked" ON "client_credential"("revoked_at")`,
},
DialectMySQL: {
"CREATE TABLE IF NOT EXISTS `client_credential` (`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `user_id` BIGINT NOT NULL, `client_name` VARCHAR(64) NOT NULL, `code_hash` VARCHAR(255) NOT NULL DEFAULT '', `code_ciphertext` VARCHAR(512) NULL, `code_version` INT NOT NULL DEFAULT 1, `created_at` DATETIME(6) NOT NULL, `updated_at` DATETIME(6) NOT NULL, `last_used_at` DATETIME(6) NULL, `revoked_at` DATETIME(6) NULL, UNIQUE KEY `uq_client_credential_user_client` (`user_id`, `client_name`), KEY `idx_client_credential_user_id` (`user_id`), KEY `idx_client_credential_revoked` (`revoked_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
},
DialectPostgres: {
`CREATE TABLE IF NOT EXISTS "client_credential" ("id" BIGSERIAL PRIMARY KEY, "user_id" BIGINT NOT NULL, "client_name" VARCHAR(64) NOT NULL, "code_hash" VARCHAR(255) NOT NULL DEFAULT '', "code_ciphertext" VARCHAR(512) NULL, "code_version" INTEGER NOT NULL DEFAULT 1, "created_at" TIMESTAMPTZ NOT NULL, "updated_at" TIMESTAMPTZ NOT NULL, "last_used_at" TIMESTAMPTZ NULL, "revoked_at" TIMESTAMPTZ NULL, UNIQUE("user_id", "client_name"))`,
`CREATE INDEX IF NOT EXISTS "idx_client_credential_user_id" ON "client_credential"("user_id")`,
`CREATE INDEX IF NOT EXISTS "idx_client_credential_revoked" ON "client_credential"("revoked_at")`,
},
}
}

View File

@ -1,4 +1,4 @@
package migration
package migration
import (
"regexp"
@ -31,6 +31,7 @@ func DefaultMigrations() []Migration {
DefineForDialects(19, "create_user_contacts_and_trusted_senders", userContactStatements()),
DefineCustom(20, "expand_protocol_credential_ciphertext", "expand_protocol_credential_ciphertext_v1", applyProtocolCredentialCiphertextSchema),
DefineCustom(21, "expand_trusted_sender_blacklist", "expand_trusted_sender_blacklist_v1", applyTrustedSenderBlacklistSchema),
DefineForDialects(22, "create_client_credentials", clientCredentialStatements()),
}
}

View File

@ -295,6 +295,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) {
19: "f24b94ca9e4c25b7973f6c15fcaddccc7f02bcf7886c0a4995d2f7b79f4a3a16",
20: "e0bc01f4c2df742bc23c519c2a399cc1c3f341565dbed014509a8c63d68bf725",
21: "ac1a0b3be90128c0e0512d2cec858f8cd7e70abc5ff4734d7113b9674f08dbb0",
22: "1047cb22f5a32c8a09ba4ccedff8713a5225c14fdb98357db86a6e0723c30d8b",
}
migrations := DefaultMigrations()
if len(migrations) != len(expected) {
@ -355,3 +356,5 @@ func TestDefaultMigrationsResumePublishedVersionOne(t *testing.T) {
})
}
}

View File

@ -39,6 +39,11 @@ func registerV1Routes(mux *http.ServeMux) {
registerV1MethodRoute(mux, "/api/v1/security/credentials/protocols/generate", "/api/security/credentials/protocols/generate", http.MethodPost, 16<<10, controllers.GenerateProtocolCredential)
registerV1MethodRoute(mux, "/api/v1/security/credentials/protocols/revoke", "/api/security/credentials/protocols/revoke", http.MethodPost, 16<<10, controllers.RevokeProtocolCredential)
registerV1MethodRoute(mux, "/api/v1/security/credentials/protocols/reveal", "/api/security/credentials/protocols/reveal", http.MethodPost, 16<<10, controllers.RevealProtocolCredentials)
registerV1MethodRoute(mux, "/api/v1/security/credentials/clients", "/api/security/credentials/clients", http.MethodGet, 0, controllers.ListClientCredentials)
registerV1MethodRoute(mux, "/api/v1/security/credentials/clients/create", "/api/security/credentials/clients/create", http.MethodPost, 16<<10, controllers.CreateClientCredential)
registerV1MethodRoute(mux, "/api/v1/security/credentials/clients/regenerate", "/api/security/credentials/clients/regenerate", http.MethodPost, 16<<10, controllers.RegenerateClientCredential)
registerV1MethodRoute(mux, "/api/v1/security/credentials/clients/revoke", "/api/security/credentials/clients/revoke", http.MethodPost, 16<<10, controllers.RevokeClientCredential)
registerV1MethodRoute(mux, "/api/v1/security/credentials/clients/reveal", "/api/security/credentials/clients/reveal", http.MethodPost, 16<<10, controllers.RevealClientCredentials)
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/verify", "/api/security/external-email/verify", http.MethodPost, 16<<10, controllers.VerifyExternalEmail)

View File

@ -72,6 +72,11 @@ func router(mux *http.ServeMux) error {
mux.HandleFunc("/api/security/credentials/protocols/generate", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.GenerateProtocolCredential))))
mux.HandleFunc("/api/security/credentials/protocols/revoke", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.RevokeProtocolCredential))))
mux.HandleFunc("/api/security/credentials/protocols/reveal", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.RevealProtocolCredentials))))
mux.HandleFunc("/api/security/credentials/clients", onlyMethod(http.MethodGet, contextIterceptor(controllers.ListClientCredentials)))
mux.HandleFunc("/api/security/credentials/clients/create", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.CreateClientCredential))))
mux.HandleFunc("/api/security/credentials/clients/regenerate", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.RegenerateClientCredential))))
mux.HandleFunc("/api/security/credentials/clients/revoke", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.RevokeClientCredential))))
mux.HandleFunc("/api/security/credentials/clients/reveal", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.RevealClientCredentials))))
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/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyExternalEmail))))

View File

@ -41,6 +41,10 @@ var loggedOperations = map[string]string{
"/api/security/credentials/protocols/generate": "生成邮件客户端安全码",
"/api/security/credentials/protocols/revoke": "撤销邮件客户端安全码",
"/api/security/credentials/protocols/reveal": "查看邮件客户端安全码",
"/api/security/credentials/clients/create": "创建客户端专属授权码",
"/api/security/credentials/clients/regenerate": "重新生成客户端授权码",
"/api/security/credentials/clients/revoke": "撤销客户端专属授权码",
"/api/security/credentials/clients/reveal": "查看客户端专属授权码",
"/api/security/external-email/bind": "申请绑定外部邮箱",
"/api/security/external-email/verify": "验证外部邮箱",
"/api/security/external-email/change": "申请修改外部邮箱",

View File

@ -0,0 +1,19 @@
package models
import "time"
// ClientCredential 保存用户为特定客户端(如 iPhone、Foxmail 等)创建的专属授权码
type ClientCredential struct {
ID int `xorm:"id bigint unsigned not null pk autoincr" json:"id"`
UserID int `xorm:"user_id bigint not null index" json:"user_id"`
ClientName string `xorm:"client_name varchar(64) notnull" json:"client_name"`
CodeHash string `xorm:"code_hash varchar(255) notnull" json:"-"`
CodeCiphertext string `xorm:"code_ciphertext varchar(512) null" json:"-"`
CodeVersion int `xorm:"code_version int notnull default(1)" json:"code_version"`
CreatedAt time.Time `xorm:"created_at datetime created notnull" json:"created_at"`
UpdatedAt time.Time `xorm:"updated_at datetime updated notnull" json:"updated_at"`
LastUsedAt *time.Time `xorm:"last_used_at datetime null" json:"last_used_at,omitempty"`
RevokedAt *time.Time `xorm:"revoked_at datetime null index" json:"revoked_at,omitempty"`
}
func (ClientCredential) TableName() string { return "client_credential" }

View File

@ -1,4 +1,4 @@
package auth
package auth
import (
"crypto/sha256"
@ -66,16 +66,30 @@ func AuthenticateProtocol(protocol string, remoteAddress string, account string,
if protocolErr != nil || db.Instance == nil {
return fail()
}
identity, err := ResolveIdentifier(account)
if err != nil || identity.Kind != IdentifierEmail || identity.UserID <= 0 {
if err != nil {
return fail()
}
userID := identity.UserID
if identity.Kind == IdentifierAccount {
var u models.User
has, err := db.Instance.Where("account = ? AND disabled = 0", identity.Account).Get(&u)
if err != nil || !has || u.ID <= 0 {
return fail()
}
userID = u.ID
}
if userID <= 0 {
return fail()
}
service, err := credentials.NewService(db.Instance)
if err != nil || !service.VerifyProtocolCode(identity.UserID, normalizedProtocol, plain) {
if err != nil || !service.VerifyClientOrProtocolCode(userID, normalizedProtocol, plain) {
return fail()
}
var user models.User
has, err := db.Instance.ID(identity.UserID).Where("disabled = 0").Get(&user)
has, err := db.Instance.ID(userID).Where("disabled = 0").Get(&user)
if err != nil || !has || user.ID <= 0 {
return fail()
}

View File

@ -0,0 +1,346 @@
package credentials
import (
"errors"
"fmt"
"strings"
"time"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/credential"
"git.pcux.cn/xuans/Smail/utils/password"
)
var (
ErrClientNameRequired = errors.New("client name is required")
ErrClientNotFound = errors.New("client credential not found")
)
// ClientCredentialItem 表示返回给前端的客户端授权码视图
type ClientCredentialItem struct {
ID int `json:"id"`
ClientName string `json:"client_name"`
Configured bool `json:"configured"`
CanReveal bool `json:"can_reveal"`
CodeVersion int `json:"code_version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
}
// CleanAuthCode 统一授权码格式:去除短横线、空格并转大写,提升各类客户端容错率
func CleanAuthCode(raw string) string {
raw = strings.ToUpper(strings.TrimSpace(raw))
raw = strings.ReplaceAll(raw, "-", "")
raw = strings.ReplaceAll(raw, " ", "")
return raw
}
// FormatAuthCode 将清洗后的授权码格式化为 4 字符一组带横线的易读格式
func FormatAuthCode(raw string) string {
cleaned := CleanAuthCode(raw)
if len(cleaned) == 0 {
return ""
}
var groups []string
for len(cleaned) > 4 {
groups = append(groups, cleaned[:4])
cleaned = cleaned[4:]
}
if len(cleaned) > 0 {
groups = append(groups, cleaned)
}
return strings.Join(groups, "-")
}
// ListClientCredentials 返回指定用户的所有有效(未撤销)客户端授权凭证
func (s *Service) ListClientCredentials(userID int) ([]ClientCredentialItem, error) {
if s == nil || s.engine == nil {
return nil, ErrUnavailable
}
if userID <= 0 {
return nil, ErrInvalidUser
}
var items []models.ClientCredential
if err := s.engine.Where("user_id = ? AND revoked_at IS NULL", userID).OrderBy("id ASC").Find(&items); err != nil {
return nil, fmt.Errorf("load client credentials: %w", err)
}
result := make([]ClientCredentialItem, 0, len(items))
for _, item := range items {
result = append(result, ClientCredentialItem{
ID: item.ID,
ClientName: item.ClientName,
Configured: item.CodeHash != "",
CanReveal: item.CodeCiphertext != "",
CodeVersion: item.CodeVersion,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
LastUsedAt: item.LastUsedAt,
})
}
return result, nil
}
// CreateClientCredential 为指定客户端名称创建专属授权码
func (s *Service) CreateClientCredential(userID int, clientName string) (string, *ClientCredentialItem, error) {
if s == nil || s.engine == nil {
return "", nil, ErrUnavailable
}
if userID <= 0 {
return "", nil, ErrInvalidUser
}
clientName = strings.TrimSpace(clientName)
if clientName == "" {
return "", nil, ErrClientNameRequired
}
if len([]rune(clientName)) > 32 {
return "", nil, errors.New("client name is too long (max 32 chars)")
}
plain, err := newProtocolCode()
if err != nil {
return "", nil, err
}
normalizedCode := CleanAuthCode(plain)
ciphertext := ""
if s.vaultRoot != "" {
ciphertext, err = credential.Encrypt(s.vaultRoot, plain)
if err != nil {
return "", nil, fmt.Errorf("protect client authorization code: %w", err)
}
}
hash, err := password.Hash(normalizedCode)
if err != nil {
return "", nil, fmt.Errorf("hash client authorization code: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
var existing models.ClientCredential
has, err := s.engine.Where("user_id = ? AND client_name = ?", userID, clientName).Get(&existing)
if err != nil {
return "", nil, fmt.Errorf("check existing client credential: %w", err)
}
if has {
// 如果此前被撤销,重新启用并更新密钥;如果未被撤销则不允许重名创建
if existing.RevokedAt == nil {
return "", nil, errors.New("client name already exists")
}
version := existing.CodeVersion + 1
existing.CodeHash = hash
existing.CodeCiphertext = ciphertext
existing.CodeVersion = version
existing.RevokedAt = nil
existing.LastUsedAt = nil
existing.UpdatedAt = s.now()
if _, err := s.engine.ID(existing.ID).Cols("code_hash", "code_ciphertext", "code_version", "revoked_at", "last_used_at", "updated_at").Update(&existing); err != nil {
return "", nil, fmt.Errorf("re-activate client credential: %w", err)
}
item := &ClientCredentialItem{
ID: existing.ID,
ClientName: existing.ClientName,
Configured: true,
CanReveal: existing.CodeCiphertext != "",
CodeVersion: existing.CodeVersion,
CreatedAt: existing.CreatedAt,
UpdatedAt: existing.UpdatedAt,
}
return plain, item, nil
}
record := models.ClientCredential{
UserID: userID,
ClientName: clientName,
CodeHash: hash,
CodeCiphertext: ciphertext,
CodeVersion: 1,
CreatedAt: s.now(),
UpdatedAt: s.now(),
}
if _, err := s.engine.Insert(&record); err != nil {
return "", nil, fmt.Errorf("insert client credential: %w", err)
}
item := &ClientCredentialItem{
ID: record.ID,
ClientName: record.ClientName,
Configured: true,
CanReveal: record.CodeCiphertext != "",
CodeVersion: record.CodeVersion,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
}
return plain, item, nil
}
// RegenerateClientCredential 重新生成已有客户端专属授权码
func (s *Service) RegenerateClientCredential(userID int, clientID int) (string, error) {
if s == nil || s.engine == nil {
return "", ErrUnavailable
}
if userID <= 0 || clientID <= 0 {
return "", ErrInvalidUser
}
plain, err := newProtocolCode()
if err != nil {
return "", err
}
normalizedCode := CleanAuthCode(plain)
ciphertext := ""
if s.vaultRoot != "" {
ciphertext, err = credential.Encrypt(s.vaultRoot, plain)
if err != nil {
return "", fmt.Errorf("protect client authorization code: %w", err)
}
}
hash, err := password.Hash(normalizedCode)
if err != nil {
return "", fmt.Errorf("hash client authorization code: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
var existing models.ClientCredential
has, err := s.engine.Where("id = ? AND user_id = ? AND revoked_at IS NULL", clientID, userID).Get(&existing)
if err != nil {
return "", fmt.Errorf("load client credential: %w", err)
}
if !has {
return "", ErrClientNotFound
}
version := existing.CodeVersion + 1
existing.CodeHash = hash
existing.CodeCiphertext = ciphertext
existing.CodeVersion = version
existing.LastUsedAt = nil
existing.UpdatedAt = s.now()
if _, err := s.engine.ID(existing.ID).Cols("code_hash", "code_ciphertext", "code_version", "last_used_at", "updated_at").Update(&existing); err != nil {
return "", fmt.Errorf("update client authorization code: %w", err)
}
return plain, nil
}
// RevokeClientCredential 撤销指定客户端授权码
func (s *Service) RevokeClientCredential(userID int, clientID int) error {
if s == nil || s.engine == nil {
return ErrUnavailable
}
if userID <= 0 || clientID <= 0 {
return ErrInvalidUser
}
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
affected, err := s.engine.Where("id = ? AND user_id = ? AND revoked_at IS NULL", clientID, userID).
Cols("revoked_at", "code_hash", "code_ciphertext", "updated_at").
Update(&models.ClientCredential{RevokedAt: &now, CodeHash: "", CodeCiphertext: ""})
if err != nil {
return fmt.Errorf("revoke client credential: %w", err)
}
if affected == 0 {
return ErrClientNotFound
}
return nil
}
// RevealClientCredentials 解密当前用户所有有效客户端授权码明文,仅在密码验证后调用
func (s *Service) RevealClientCredentials(userID int) (map[int]string, error) {
if s == nil || s.engine == nil {
return nil, ErrUnavailable
}
if userID <= 0 {
return nil, ErrInvalidUser
}
s.mu.Lock()
defer s.mu.Unlock()
vaultRoot := s.vaultRoot
if vaultRoot == "" {
return make(map[int]string), nil
}
var items []models.ClientCredential
if err := s.engine.Where("user_id = ? AND revoked_at IS NULL", userID).Find(&items); err != nil {
return nil, fmt.Errorf("load client credentials for reveal: %w", err)
}
result := make(map[int]string, len(items))
for _, item := range items {
if item.CodeCiphertext == "" || item.CodeHash == "" {
continue
}
plain, err := credential.Decrypt(vaultRoot, item.CodeCiphertext)
if err != nil {
continue
}
result[item.ID] = plain
}
return result, nil
}
// VerifyClientOrProtocolCode 同时支持客户端专属授权码以及遗留的协议授权码
func (s *Service) VerifyClientOrProtocolCode(userID int, protocol string, rawPlain string) bool {
if s == nil || s.engine == nil || userID <= 0 || rawPlain == "" || len([]byte(rawPlain)) > 256 {
password.VerifyDummy(rawPlain)
return false
}
cleanedPlain := CleanAuthCode(rawPlain)
if cleanedPlain == "" {
password.VerifyDummy(rawPlain)
return false
}
// 1. 优先校验客户端专属授权码
var clients []models.ClientCredential
err := s.engine.Where("user_id = ? AND revoked_at IS NULL AND code_hash != ''", userID).Find(&clients)
if err == nil && len(clients) > 0 {
for _, client := range clients {
// 比对清洗后的统一大写无横线授权码
if password.Verify(client.CodeHash, cleanedPlain) {
now := s.now()
_, _ = s.engine.ID(client.ID).
Where("revoked_at IS NULL AND code_version = ? AND code_hash = ?", client.CodeVersion, client.CodeHash).
Cols("last_used_at", "updated_at").Update(&models.ClientCredential{LastUsedAt: &now})
return true
}
}
}
// 2. 回退校验旧版按协议划分的授权码(支持 cleanedPlain 或原樣 rawPlain)
protocol, err = NormalizeProtocol(protocol)
if err != nil {
password.VerifyDummy(rawPlain)
return false
}
var item models.ProtocolCredential
has, err := s.engine.Where("user_id = ? AND protocol = ? AND enabled = ? AND revoked_at IS NULL", userID, protocol, true).Get(&item)
if err != nil || !has || item.CodeHash == "" {
password.VerifyDummy(rawPlain)
return false
}
// 兼容旧哈希直接比对原始输入,或者比对清洗后的输入
matched := password.Verify(item.CodeHash, strings.TrimSpace(rawPlain)) || password.Verify(item.CodeHash, cleanedPlain)
if !matched {
return false
}
now := s.now()
affected, err := s.engine.ID(item.ID).
Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash).
Cols("last_used_at", "updated_at").Update(&models.ProtocolCredential{LastUsedAt: &now})
if err == nil && affected == 1 {
return true
}
valid, err := s.engine.ID(item.ID).
Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash).
Exist(&models.ProtocolCredential{})
return err == nil && valid
}

View File

@ -1,163 +1,283 @@
.third-party-authorization {
.third-party-authorization {
padding: 0;
}
.authorization-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.authorization-header .header-main {
flex: 1;
min-width: 0;
}
.authorization-header h3 {
margin: 0 0 8px;
color: var(--pm-text-primary);
font-size: 20px;
font-weight: 700;
}
.authorization-desc {
margin: 0;
color: var(--pm-text-secondary);
font-size: 14px;
line-height: 1.7;
}
.authorization-header .header-actions {
flex: none;
display: flex;
align-items: center;
}
.authorization-section > .el-alert {
margin-bottom: 18px;
}
.protocol-list {
display: flex;
flex-direction: column;
gap: 16px;
gap: 12px;
}
.protocol-item {
.authorization-section {
display: flex;
flex-direction: column;
gap: 12px;
}
/* 顶部轻量工具条 */
.compact-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 16px 20px;
border-radius: var(--pm-radius-md);
border: 1px solid var(--pm-border-color);
gap: 12px;
padding: 10px 14px;
background: var(--pm-bg-secondary);
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-md);
}
.protocol-info {
.header-left {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
flex: 1;
}
.protocol-title {
.header-title {
font-size: 15px;
font-weight: 700;
color: var(--pm-text-primary);
white-space: nowrap;
}
.header-subtitle {
font-size: 12px;
color: var(--pm-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* 客户端选择与管理主卡片 */
.client-main-card {
padding: 12px 14px;
background: var(--pm-bg-secondary);
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-md);
display: flex;
flex-direction: column;
gap: 10px;
}
.protocol-title strong {
color: var(--pm-text-primary);
font-size: 16px;
}
.protocol-info .protocol-guide {
margin: 6px 0 0;
color: var(--pm-text-secondary);
font-size: 13px;
}
.protocol-credential-slot {
.client-picker-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
padding: 8px 12px;
border-radius: var(--pm-radius-sm);
background: var(--pm-bg-primary);
border: 1px dashed var(--pm-border-color);
font-size: 13px;
}
.slot-label {
.picker-group {
display: flex;
align-items: center;
gap: 8px;
}
.picker-label {
font-size: 13px;
font-weight: 600;
color: var(--pm-text-primary);
white-space: nowrap;
}
.client-select {
width: 220px;
}
.client-option-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
}
.picker-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* 授权码详情框 */
.client-detail-box {
padding: 10px 12px;
background: var(--pm-bg-primary);
border: 1px dashed var(--pm-border-color);
border-radius: var(--pm-radius-sm);
}
.code-display-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.code-label {
font-size: 13px;
font-weight: 600;
color: var(--pm-text-secondary);
font-weight: 500;
white-space: nowrap;
}
.code-value-area {
display: flex;
align-items: center;
gap: 8px;
}
.masked-code {
letter-spacing: 2px;
color: var(--pm-text-secondary);
font-family: monospace;
font-size: 14px;
color: var(--pm-text-secondary);
}
.revealed-code {
padding: 2px 6px;
padding: 2px 8px;
border-radius: 4px;
background: color-mix(in srgb, var(--pm-primary-color) 12%, var(--pm-bg-secondary));
color: var(--pm-primary-color);
font-family: monospace;
font-size: 14px;
font-weight: 600;
font-weight: 700;
letter-spacing: 1px;
}
.reveal-btn, .copy-btn {
padding: 0 4px;
.action-btn {
font-size: 13px;
padding: 0 4px;
}
.unconfigured-tip {
.text-tip {
font-size: 12px;
color: var(--pm-text-placeholder, #909399);
font-style: italic;
}
.protocol-actions {
.client-meta {
display: flex;
flex: none;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: var(--pm-text-secondary);
margin-left: auto;
}
.empty-client-box {
padding: 12px;
text-align: center;
background: var(--pm-bg-primary);
border: 1px dashed var(--pm-border-color);
border-radius: var(--pm-radius-sm);
}
.empty-tip {
font-size: 13px;
color: var(--pm-text-placeholder, #909399);
}
/* 紧凑指引网格 */
.compact-guide-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.verify-dialog-tip {
margin: 0 0 16px;
color: var(--pm-text-secondary);
font-size: 14px;
line-height: 1.6;
.guide-title {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: var(--pm-text-primary);
}
.verify-dialog-footer {
.guide-sub {
font-size: 12px;
font-weight: normal;
color: var(--pm-text-secondary);
}
.guide-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.guide-card {
padding: 10px 12px;
background: var(--pm-bg-secondary);
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-md);
display: flex;
flex-direction: column;
gap: 6px;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2px;
}
.card-proto {
font-size: 13px;
font-weight: 700;
color: var(--pm-text-primary);
}
.card-line {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
}
.cl-label {
color: var(--pm-text-secondary);
}
.cl-val {
font-family: monospace;
color: var(--pm-text-primary);
font-weight: 500;
}
.cl-val.highlight {
color: var(--pm-primary-color);
font-weight: 700;
}
/* 弹窗通用紧凑样式 */
.compact-dialog .dialog-tip {
margin: 0 0 14px;
color: var(--pm-text-secondary);
font-size: 13px;
line-height: 1.5;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
gap: 10px;
}
@media (max-width: 720px) {
.authorization-header {
@media (max-width: 800px) {
.guide-grid {
grid-template-columns: 1fr;
}
.compact-header {
flex-direction: column;
align-items: stretch;
}
.protocol-item {
align-items: stretch;
.client-picker-row {
flex-direction: column;
gap: 14px;
align-items: stretch;
}
.protocol-actions {
justify-content: flex-start;
}
}
}

View File

@ -1,194 +1,231 @@
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessageBox, ElNotification } from 'element-plus'
import { http } from '@/utils/axios'
export function usePage() {
const protocolLoading = ref(false)
const protocolCredentials = ref([])
const generatedProtocolCode = ref('')
const generatedProtocol = ref('')
const loading = ref(false)
const clientList = ref([])
const selectedClientId = ref(null)
const currentDomain = ref('')
// 解密后的各协议授权码明文 { imap: 'xxxx', pop3: 'yyyy', smtp: 'zzzz' }
// 解密后的各客户端授权码明文 { [clientId]: 'xxxx-yyyy-...' }
const revealedCodes = reactive({})
// 是否已完成密码安全验证并展示
const codesRevealed = ref(false)
// 新增客户端弹窗
const createDialogVisible = ref(false)
const newClientName = ref('')
const createLoading = ref(false)
// 密码验证查看弹窗
const verifyDialogVisible = ref(false)
const verifyPassword = ref('')
const verifyLoading = ref(false)
const currentClient = computed(() => {
if (!selectedClientId.value || !Array.isArray(clientList.value)) return null
return clientList.value.find(c => c.id === selectedClientId.value) || null
})
const notify = (message, type = 'error') => {
ElNotification({
title: type === 'success' ? '操作成功' : '操作失败',
title: type === 'success' ? '操作成功' : (type === 'info' ? '提示' : '操作失败'),
message,
type,
})
}
const protocolLabel = (protocol = '') => {
const map = {
smtp: 'SMTP (代发/发信)',
imap: 'IMAP (收信/同步)',
pop3: 'POP3 (收信)',
const formatTime = (timeStr) => {
if (!timeStr) return '--'
try {
const d = new Date(timeStr)
if (isNaN(d.getTime())) return timeStr
return d.toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return timeStr
}
return map[protocol.toLowerCase()] || protocol.toUpperCase()
}
const loadProtocolCredentials = async () => {
protocolLoading.value = true
const loadBaseInfo = async () => {
try {
const response = await http.get('/api/v1/security/credentials/protocols')
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || '读取邮件客户端协议设置失败')
const infoRes = await http.get('/api/v1/user/info')
if (infoRes?.data?.email && infoRes.data.email.includes('@')) {
currentDomain.value = infoRes.data.email.split('@')[1]
}
protocolCredentials.value = Array.isArray(response.data) ? response.data : []
} catch (error) {
notify(error?.message || '读取邮件客户端协议设置失败')
} finally {
protocolLoading.value = false
} catch {
// 忽略
}
}
const setProtocolEnabled = async (item, enabled) => {
if (protocolLoading.value || item.enabled === enabled) return
const previous = item.enabled
item.enabled = enabled
protocolLoading.value = true
const loadClientCredentials = async () => {
loading.value = true
try {
const response = await http.put('/api/v1/security/credentials/protocols/enabled', {
protocol: item.protocol,
enabled,
const response = await http.get('/api/v1/security/credentials/clients')
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || '读取客户端授权码列表失败')
}
const list = Array.isArray(response.data) ? response.data : []
clientList.value = list
if (list.length > 0) {
// 如果当前选中的不在列表中,默认选中第一个
if (!selectedClientId.value || !list.some(c => c.id === selectedClientId.value)) {
selectedClientId.value = list[0].id
}
} else {
selectedClientId.value = null
}
} catch (error) {
notify(error?.message || '读取客户端授权码列表失败')
} finally {
loading.value = false
}
}
const handleClientSelect = (val) => {
selectedClientId.value = val
}
const openCreateDialog = () => {
newClientName.value = ''
createDialogVisible.value = true
}
const closeCreateDialog = () => {
createDialogVisible.value = false
newClientName.value = ''
}
const submitCreateClient = async () => {
const name = newClientName.value.trim()
if (!name) {
notify('请输入客户端名称')
return
}
createLoading.value = true
try {
const response = await http.post('/api/v1/security/credentials/clients/create', {
client_name: name,
})
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || '更新协议开启状态失败')
throw new Error(response.errorMsg || '创建客户端专属授权码失败')
}
if (!enabled) {
if (generatedProtocol.value === item.protocol) {
generatedProtocol.value = ''
generatedProtocolCode.value = ''
}
delete revealedCodes[item.protocol]
const code = response.data?.code || ''
const item = response.data?.client
closeCreateDialog()
await loadClientCredentials()
if (item && item.id) {
selectedClientId.value = item.id
revealedCodes[item.id] = code
codesRevealed.value = true
}
notify(`${protocolLabel(item.protocol)} 当前账号客户端访问已${enabled ? '开启' : '关闭'}`, 'success')
await loadProtocolCredentials()
notify(`客户端「${name}」专属授权码已创建并就绪`, 'success')
} catch (error) {
item.enabled = previous
notify(error?.message || '更新协议开启状态失败')
notify(error?.message || '创建客户端授权码失败')
} finally {
protocolLoading.value = false
createLoading.value = false
}
}
const copyProtocolCode = async (codeToCopy) => {
const targetCode = typeof codeToCopy === 'string' && codeToCopy
? codeToCopy
: generatedProtocolCode.value
if (!targetCode) {
notify('未找到可复制的授权码')
const regenerateClientCode = async (client) => {
if (!client) return
try {
await ElMessageBox.confirm(
`确定重新生成客户端「${client.client_name}」的专属授权码吗?旧授权码将立即失效。`,
'安全提示',
{
confirmButtonText: '确定重新生成',
cancelButtonText: '取消',
type: 'warning',
}
)
} catch {
return
}
loading.value = true
try {
const response = await http.post('/api/v1/security/credentials/clients/regenerate', {
client_id: client.id,
})
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || '重新生成客户端授权码失败')
}
const newCode = response.data?.code || ''
revealedCodes[client.id] = newCode
codesRevealed.value = true
notify(`客户端「${client.client_name}」专属授权码已重新生成`, 'success')
await loadClientCredentials()
} catch (error) {
notify(error?.message || '重新生成失败')
} finally {
loading.value = false
}
}
const revokeClientCode = async (client) => {
if (!client) return
try {
await ElMessageBox.confirm(
`确定撤销并删除客户端「${client.client_name}」吗?撤销后该设备将无法通过授权码登录收发信。`,
'安全提示',
{
confirmButtonText: '确定撤销删除',
cancelButtonText: '取消',
type: 'danger',
}
)
} catch {
return
}
loading.value = true
try {
const response = await http.post('/api/v1/security/credentials/clients/revoke', {
client_id: client.id,
})
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || '撤销客户端失败')
}
delete revealedCodes[client.id]
notify(`客户端「${client.client_name}」已成功撤销`, 'success')
await loadClientCredentials()
} catch (error) {
notify(error?.message || '撤销失败')
} finally {
loading.value = false
}
}
const copyCode = async (codeText) => {
if (!codeText) {
notify('未找到可复制的授权码')
return
}
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(targetCode)
await navigator.clipboard.writeText(codeText)
} else {
const input = document.createElement('input')
input.value = targetCode
input.value = codeText
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
}
notify('协议授权码已复制到剪贴板', 'success')
} catch (error) {
notify('专属授权码已复制到剪贴板', 'success')
} catch {
notify('复制失败,请手动选择复制')
}
}
const generateProtocolCode = async (item) => {
const actionText = item.configured ? '重新生成' : '生成'
try {
await ElMessageBox.confirm(
`确定为 ${protocolLabel(item.protocol)} ${actionText}专属授权码吗?旧授权码将立即失效并更新。`,
'安全提示',
{
confirmButtonText: '确定生成',
cancelButtonText: '取消',
type: 'warning',
}
)
} catch {
return
}
protocolLoading.value = true
try {
const response = await http.post('/api/v1/security/credentials/protocols/generate', {
protocol: item.protocol,
})
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || '生成协议授权码失败')
}
const newCode = response?.data?.code || ''
generatedProtocol.value = item.protocol
generatedProtocolCode.value = newCode
// 自动同步到已解密展示状态,无需再次密码验证
revealedCodes[item.protocol] = newCode
codesRevealed.value = true
notify(`${protocolLabel(item.protocol)} 授权码已生成并持久保存`, 'success')
await loadProtocolCredentials()
} catch (error) {
notify(error?.message || '生成协议授权码失败')
} finally {
protocolLoading.value = false
}
}
const revokeProtocolCode = async (item) => {
try {
await ElMessageBox.confirm(
`确定撤销 ${protocolLabel(item.protocol)} 的授权码吗?撤销后客户端将无法使用该授权码登录。`,
'安全提示',
{
confirmButtonText: '确定撤销',
cancelButtonText: '取消',
type: 'warning',
}
)
} catch {
return
}
protocolLoading.value = true
try {
const response = await http.post('/api/v1/security/credentials/protocols/revoke', {
protocol: item.protocol,
})
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || '撤销协议授权码失败')
}
delete revealedCodes[item.protocol]
if (generatedProtocol.value === item.protocol) {
generatedProtocol.value = ''
generatedProtocolCode.value = ''
}
notify(`${protocolLabel(item.protocol)} 授权码已撤销`, 'success')
await loadProtocolCredentials()
} catch (error) {
notify(error?.message || '撤销协议授权码失败')
} finally {
protocolLoading.value = false
}
}
// 密码验证弹窗控制
const openVerifyDialog = () => {
verifyPassword.value = ''
verifyDialogVisible.value = true
@ -199,33 +236,30 @@ export function usePage() {
verifyPassword.value = ''
}
// 提交登录密码验证并解密回显
const submitVerifyAndReveal = async () => {
const password = verifyPassword.value.trim()
if (!password) {
notify('请输入当前账号登录密码')
return
}
verifyLoading.value = true
try {
const response = await http.post('/api/v1/security/credentials/protocols/reveal', {
const response = await http.post('/api/v1/security/credentials/clients/reveal', {
password,
})
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || '密码验证失败,无法查看授权码')
}
const codes = response.data?.codes || response.data || {}
const map = response.data || {}
Object.keys(revealedCodes).forEach(k => delete revealedCodes[k])
Object.assign(revealedCodes, codes)
Object.assign(revealedCodes, map)
codesRevealed.value = true
closeVerifyDialog()
const count = Object.keys(codes).length
const count = Object.keys(map).length
if (count > 0) {
notify(`安全验证通过,已解密显示 ${count} 个协议授权码`, 'success')
notify(`安全验证通过,已解密显示客户端专属授权码`, 'success')
} else {
notify('安全验证通过,当前授权码未保留加密密文,可点击重新生成', 'warning')
notify('安全验证通过,当前未发现加密密文,可重新生成', 'warning')
}
} catch (error) {
notify(error?.message || '验证失败,请检查密码是否正确')
@ -234,35 +268,42 @@ export function usePage() {
}
}
// 安全隐藏授权码
const hideRevealedCodes = () => {
Object.keys(revealedCodes).forEach(k => delete revealedCodes[k])
codesRevealed.value = false
notify('已恢复安全隐藏保护', 'info')
notify('已恢复安全隐藏状态', 'info')
}
onMounted(() => {
loadProtocolCredentials()
loadBaseInfo()
loadClientCredentials()
})
return {
protocolLoading,
protocolCredentials,
generatedProtocolCode,
generatedProtocol,
loading,
clientList,
selectedClientId,
currentClient,
currentDomain,
revealedCodes,
codesRevealed,
createDialogVisible,
newClientName,
createLoading,
verifyDialogVisible,
verifyPassword,
verifyLoading,
protocolLabel,
setProtocolEnabled,
generateProtocolCode,
copyProtocolCode,
revokeProtocolCode,
handleClientSelect,
openCreateDialog,
closeCreateDialog,
submitCreateClient,
regenerateClientCode,
revokeClientCode,
copyCode,
formatTime,
openVerifyDialog,
closeVerifyDialog,
submitVerifyAndReveal,
hideRevealedCodes,
}
}
}

View File

@ -1,22 +1,21 @@
<template>
<div class="third-party-authorization" v-loading="protocolLoading">
<template>
<div class="third-party-authorization" v-loading="loading">
<section class="authorization-section">
<div class="authorization-header">
<div class="header-main">
<h3>邮件客户端协议</h3>
<p class="authorization-desc">
为 IMAP、POP3、SMTP 配置专属授权码,平时安全隐藏,验证密码后可查看或复制。
</p>
<!-- 顶部轻量工具条:紧凑一行 -->
<div class="compact-header">
<div class="header-left">
<span class="header-title">客户端专属授权码</span>
<span class="header-subtitle">为手机、Foxmail 等设备创建独立授权码,随时查看或撤销</span>
</div>
<div class="header-actions">
<div class="header-right">
<el-button
v-if="hasConfiguredProtocols && !codesRevealed"
v-if="hasClients && !codesRevealed"
type="primary"
plain
size="small"
@click="openVerifyDialog"
>
安全验证查看授权码
安全验证查看明文
</el-button>
<el-button
v-else-if="codesRevealed"
@ -25,125 +24,219 @@
size="small"
@click="hideRevealedCodes"
>
安全隐藏授权码
安全隐藏明文
</el-button>
<el-button
type="primary"
size="small"
@click="openCreateDialog"
>
+ 新增客户端
</el-button>
</div>
</div>
<el-alert
title="使用完整邮箱地址作为登录名,专属授权码作为密码。授权码平时处于加密保护状态,验证密码后可完整查看与一键复制。"
type="info"
:closable="false"
show-icon
/>
<div class="protocol-list">
<div
v-for="item in protocolCredentials"
:key="item.protocol"
class="protocol-item"
>
<div class="protocol-info">
<div class="protocol-title">
<strong>{{ protocolLabel(item.protocol) }}</strong>
<el-tag size="small" :type="item.enabled ? 'success' : 'info'">
{{ item.enabled ? '已开启' : '已关闭' }}
</el-tag>
<el-tag size="small" :type="item.configured ? 'success' : 'warning'">
{{ item.configured ? '已配置授权码' : '未生成授权码' }}
</el-tag>
</div>
<p class="protocol-guide">登录名:完整邮箱地址;密码:本协议专属授权码。</p>
<div class="protocol-credential-slot">
<span class="slot-label">当前授权码:</span>
<template v-if="item.configured">
<template v-if="codesRevealed && revealedCodes[item.protocol]">
<code class="revealed-code">{{ revealedCodes[item.protocol] }}</code>
<el-button
type="primary"
link
size="small"
class="copy-btn"
@click="copyProtocolCode(revealedCodes[item.protocol])"
>
复制
</el-button>
</template>
<template v-else-if="codesRevealed && !revealedCodes[item.protocol]">
<span class="unconfigured-tip">旧版授权码未保留密文,请点击重新生成</span>
<el-button
type="primary"
link
size="small"
class="copy-btn"
@click="generateProtocolCode(item)"
>
重新生成
</el-button>
</template>
<template v-else>
<span class="masked-code">••••••••••••••••</span>
<el-button
type="primary"
link
size="small"
class="reveal-btn"
@click="openVerifyDialog"
>
验证查看
</el-button>
</template>
</template>
<template v-else>
<span class="unconfigured-tip">尚未生成专属授权码,请点击右侧生成</span>
</template>
</div>
<!-- 客户端选择与管理主卡片 -->
<div class="client-main-card">
<div class="client-picker-row">
<div class="picker-group">
<span class="picker-label">选择客户端:</span>
<el-select
v-model="selectedClientId"
placeholder="请选择或添加客户端"
size="default"
class="client-select"
@change="handleClientSelect"
>
<el-option
v-for="item in clientList"
:key="item.id"
:label="item.client_name"
:value="item.id"
>
<div class="client-option-item">
<span>{{ item.client_name }}</span>
<el-tag size="small" type="success">已就绪</el-tag>
</div>
</el-option>
</el-select>
</div>
<div class="protocol-actions">
<el-switch
:model-value="item.enabled"
:loading="protocolLoading"
active-text="开启"
inactive-text="关闭"
:aria-label="`${protocolLabel(item.protocol)} 当前账号客户端访问`"
@change="value => setProtocolEnabled(item, value)"
/>
<div class="picker-actions" v-if="currentClient">
<el-button
type="primary"
type="warning"
plain
size="small"
:disabled="!item.enabled"
@click="generateProtocolCode(item)"
@click="regenerateClientCode(currentClient)"
>
{{ item.configured ? '重新生成授权码' : '生成授权码' }}
重新生成
</el-button>
<el-button
v-if="item.configured"
type="danger"
plain
size="small"
@click="revokeProtocolCode(item)"
@click="revokeClientCode(currentClient)"
>
撤销授权码
撤销删除
</el-button>
</div>
</div>
<!-- 当前选中客户端的授权码展示条 -->
<div class="client-detail-box" v-if="currentClient">
<div class="code-display-row">
<span class="code-label">当前专属授权码:</span>
<div class="code-value-area">
<template v-if="codesRevealed && revealedCodes[currentClient.id]">
<code class="revealed-code">{{ revealedCodes[currentClient.id] }}</code>
<el-button
type="primary"
link
size="small"
class="action-btn"
@click="copyCode(revealedCodes[currentClient.id])"
>
一键复制
</el-button>
</template>
<template v-else-if="codesRevealed && !revealedCodes[currentClient.id]">
<span class="text-tip">未保留加密密文,可点击重新生成</span>
</template>
<template v-else>
<span class="masked-code">••••••••••••••••••••</span>
<el-button
type="primary"
link
size="small"
class="action-btn"
@click="openVerifyDialog"
>
验证查看
</el-button>
</template>
</div>
<div class="client-meta">
<span class="meta-item">创建时间:{{ formatTime(currentClient.created_at) }}</span>
<span class="meta-item" v-if="currentClient.last_used_at">
最近活跃:{{ formatTime(currentClient.last_used_at) }}
</span>
</div>
</div>
</div>
<div class="empty-client-box" v-else>
<span class="empty-tip">暂无可用客户端,请点击上方「+ 新增客户端」为您的设备配置专属授权码</span>
</div>
</div>
<!-- 客户端配置指引紧凑网格(一屏显示完整,无滚动条) -->
<div class="compact-guide-section">
<div class="guide-title">
<span>通用客户端配置参数</span>
<span class="guide-sub">支持用户名直接登录或完整邮箱登录,密码统一使用上方专属授权码</span>
</div>
<div class="guide-grid">
<div class="guide-card">
<div class="card-head">
<span class="card-proto">IMAP 收信服务</span>
<el-tag size="small" type="primary">SSL/TLS</el-tag>
</div>
<div class="card-line">
<span class="cl-label">服务器地址:</span>
<span class="cl-val">imap.{{ currentDomain || 'solit.cn' }}</span>
</div>
<div class="card-line">
<span class="cl-label">服务端口:</span>
<span class="cl-val highlight">993</span>
</div>
</div>
<div class="guide-card">
<div class="card-head">
<span class="card-proto">POP3 收信服务</span>
<el-tag size="small" type="primary">SSL/TLS</el-tag>
</div>
<div class="card-line">
<span class="cl-label">服务器地址:</span>
<span class="cl-val">pop.{{ currentDomain || 'solit.cn' }}</span>
</div>
<div class="card-line">
<span class="cl-label">服务端口:</span>
<span class="cl-val highlight">995</span>
</div>
</div>
<div class="guide-card">
<div class="card-head">
<span class="card-proto">SMTP 发信服务</span>
<el-tag size="small" type="primary">SSL / STARTTLS</el-tag>
</div>
<div class="card-line">
<span class="cl-label">服务器地址:</span>
<span class="cl-val">smtp.{{ currentDomain || 'solit.cn' }}</span>
</div>
<div class="card-line">
<span class="cl-label">服务端口:</span>
<span class="cl-val highlight">465 (SSL) / 587</span>
</div>
</div>
</div>
</div>
</section>
<!-- 新增客户端弹窗 -->
<el-dialog
v-model="createDialogVisible"
title="新增客户端专属授权码"
width="min(440px, calc(100vw - 32px))"
:close-on-click-modal="false"
class="compact-dialog"
@close="closeCreateDialog"
>
<div class="dialog-body">
<p class="dialog-tip">
为您的设备(例如“iPhone 自带邮件”、“公司 Foxmail”、“Mac Mail”)创建独立授权码:
</p>
<el-form @submit.prevent="submitCreateClient">
<el-form-item label="客户端名称">
<el-input
v-model="newClientName"
placeholder="例如:iPhone 邮件、Foxmail 办公本"
maxlength="32"
show-word-limit
autocomplete="off"
@keyup.enter="submitCreateClient"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeCreateDialog">取消</el-button>
<el-button
type="primary"
:loading="createLoading"
:disabled="!newClientName.trim()"
@click="submitCreateClient"
>
立即生成
</el-button>
</div>
</template>
</el-dialog>
<!-- 密码验证弹窗 -->
<el-dialog
v-model="verifyDialogVisible"
title="安全身份验证"
width="min(440px, calc(100vw - 32px))"
:close-on-click-modal="false"
class="verify-reveal-dialog"
class="compact-dialog"
@close="closeVerifyDialog"
>
<div class="verify-dialog-body">
<p class="verify-dialog-tip">
查看已持久保存的协议授权码前,请先验证当前账号的登录密码:
<div class="dialog-body">
<p class="dialog-tip">
查看已保存的客户端专属授权码明文前,请先验证当前账号的登录密码:
</p>
<el-form @submit.prevent="submitVerifyAndReveal">
<el-form-item label="登录密码">
@ -159,7 +252,7 @@
</el-form>
</div>
<template #footer>
<div class="verify-dialog-footer">
<div class="dialog-footer">
<el-button @click="closeVerifyDialog">取消</el-button>
<el-button
type="primary"
@ -167,7 +260,7 @@
:disabled="!verifyPassword.trim()"
@click="submitVerifyAndReveal"
>
验证并查看授权码
验证并查看明文
</el-button>
</div>
</template>
@ -180,28 +273,36 @@ import { computed } from 'vue'
import { usePage } from './ThirdPartyAuthorization.js'
const {
protocolLoading,
protocolCredentials,
loading,
clientList,
selectedClientId,
currentClient,
currentDomain,
revealedCodes,
codesRevealed,
createDialogVisible,
newClientName,
createLoading,
verifyDialogVisible,
verifyPassword,
verifyLoading,
protocolLabel,
setProtocolEnabled,
generateProtocolCode,
copyProtocolCode,
revokeProtocolCode,
handleClientSelect,
openCreateDialog,
closeCreateDialog,
submitCreateClient,
regenerateClientCode,
revokeClientCode,
copyCode,
formatTime,
openVerifyDialog,
closeVerifyDialog,
submitVerifyAndReveal,
hideRevealedCodes,
} = usePage()
const hasConfiguredProtocols = computed(() =>
Array.isArray(protocolCredentials.value) &&
protocolCredentials.value.some(item => item.configured)
const hasClients = computed(() =>
Array.isArray(clientList.value) && clientList.value.length > 0
)
</script>
<style scoped src="./ThirdPartyAuthorization.css"></style>
<style scoped src="./ThirdPartyAuthorization.css"></style>