feat(api): 新增开放 API 与 Token 管理

- 新增 API Token 的创建、查询、更新和撤销接口,并支持 Bearer 认证
- 新增 `/api/open` 域名、邮箱、发信与消息查询接口,补充权限校验
- 更新数据库迁移、路由与测试,并新增开放 API 文档
This commit is contained in:
LanQin_
2026-06-29 15:31:20 +08:00
parent eb8b999ce5
commit f87ce5fa4f
10 changed files with 1619 additions and 4 deletions
+70 -1
View File
@@ -37,6 +37,10 @@ func (a *App) Router() http.Handler {
r.With(a.requireAuth).Get("/me", a.handleMe)
r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile)
r.With(a.requireAuth).Post("/me/password", a.handleChangePassword)
r.With(a.requireAuth).Get("/me/api-tokens", a.handleListAPITokens)
r.With(a.requireAuth).Post("/me/api-tokens", a.handleCreateAPIToken)
r.With(a.requireAuth).Post("/me/api-tokens/{id}", a.handleUpdateAPIToken)
r.With(a.requireAuth).Delete("/me/api-tokens/{id}", a.handleDeleteAPIToken)
r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions)
r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Post("/me/mailboxes/apply", a.handleApplyMailbox)
r.With(a.requireAuth).Post("/me/2fa/setup", a.handleTwoFactorSetup)
@@ -71,6 +75,23 @@ func (a *App) Router() http.Handler {
r.With(a.requireExternalIMAPEnabled).Get("/external-imap-oauth/{provider}/callback", a.handleExternalIMAPOAuthCallback)
r.With(a.requireAuth).Get("/events", a.handleEvents)
r.Group(func(r chi.Router) {
r.Use(a.requireAPIToken)
r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/open/domains", a.handlePublicAPIListDomains)
r.With(a.requireAdminAccess, a.requirePermission(PermissionDomainsCreate)).Post("/open/domains", a.handlePublicAPICreateDomain)
r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/open/domains/{id}", a.handlePublicAPIGetDomain)
r.With(a.requireAdminAccess, a.requirePermission(PermissionDomainsUpdate)).Post("/open/domains/{id}", a.handlePublicAPIUpdateDomain)
r.With(a.requireAdminAccess, a.requirePermission(PermissionDomainsDelete)).Delete("/open/domains/{id}", a.handlePublicAPIDeleteDomain)
r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/open/mailboxes", a.handlePublicAPIListMailboxes)
r.With(a.requireAdminAccess, a.requirePermission(PermissionMailboxesCreate)).Post("/open/mailboxes", a.handlePublicAPICreateMailbox)
r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/open/mailboxes/{id}", a.handlePublicAPIGetMailbox)
r.With(a.requireAdminAccess, a.requirePermission(PermissionMailboxesUpdate)).Post("/open/mailboxes/{id}", a.handlePublicAPIUpdateMailbox)
r.With(a.requireAdminAccess, a.requirePermission(PermissionMailboxesDelete)).Delete("/open/mailboxes/{id}", a.handlePublicAPIDeleteMailbox)
r.With(a.requirePermission(PermissionMailSend)).Post("/open/send", a.handlePublicAPISendMail)
r.With(a.requirePermission(PermissionMailRead)).Get("/open/send/{id}", a.handlePublicAPISendStatus)
r.With(a.requirePermission(PermissionMailRead)).Get("/open/mailboxes/{id}/messages", a.handlePublicAPIMailboxMessages)
})
r.Group(func(r chi.Router) {
r.Use(a.requireAuth)
r.With(a.requirePermission(PermissionMailAccess)).Get("/mail/mailboxes", a.handleMyMailboxes)
@@ -165,7 +186,7 @@ func (a *App) corsMiddleware(next http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS")
}
if r.Method == http.MethodOptions {
@@ -187,6 +208,17 @@ func (a *App) requireAuth(next http.Handler) http.Handler {
})
}
func (a *App) requireAPIToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := a.authenticateAPIToken(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "api token required")
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
})
}
func currentUser(r *http.Request) *User {
user, _ := r.Context().Value(userContextKey).(*User)
return user
@@ -218,6 +250,43 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
return &u, nil
}
func (a *App) authenticateAPIToken(r *http.Request) (*User, error) {
token := bearerToken(r)
if token == "" {
return nil, errors.New("no api token")
}
now := a.now().UTC().Format(time.RFC3339Nano)
row := a.db.QueryRowContext(r.Context(), `SELECT at.id,u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at
FROM api_tokens at JOIN users u ON u.id=at.user_id
WHERE at.token_hash=? AND at.disabled=0 AND (at.expires_at IS NULL OR at.expires_at > ?)`, hashToken(token), now)
var tokenID string
var u User
var disabled, twoFactorEnabled int
var created string
if err := row.Scan(&tokenID, &u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil {
return nil, err
}
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.CreatedAt = parseTime(created)
if u.Disabled {
return nil, errors.New("disabled")
}
if err := a.attachUserAuthorization(r.Context(), &u); err != nil {
return nil, err
}
_, _ = a.db.ExecContext(r.Context(), `UPDATE api_tokens SET last_used_at=? WHERE id=?`, now, tokenID)
return &u, nil
}
func bearerToken(r *http.Request) string {
fields := strings.Fields(strings.TrimSpace(r.Header.Get("Authorization")))
if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") {
return ""
}
return fields[1]
}
func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,password_hash,disabled,two_factor_enabled,created_at FROM users WHERE email=?`, email)
var u User