chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
98
server/internal/middleware/auth.go
Normal file
98
server/internal/middleware/auth.go
Normal file
@ -0,0 +1,98 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const AgentNameKey contextKey = "agent_name"
|
||||
|
||||
// bearerToken 从 Authorization: Bearer <token> 取出令牌,缺失时返回空串。
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
const p = "Bearer "
|
||||
if len(h) > len(p) && strings.EqualFold(h[:len(p)], p) {
|
||||
return strings.TrimSpace(h[len(p):])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BearerToken 导出给 handler 层用(注册接口不过中间件,需要自己取密钥)。
|
||||
func BearerToken(r *http.Request) string { return bearerToken(r) }
|
||||
|
||||
// keyAuthError 把密钥校验错误翻译成对外文案。
|
||||
// 「不存在」与「已使用/已过期」区分开:前两者是拿错了密钥,后者是密钥生命周期到了,
|
||||
// 运维需要据此判断该重新签发还是该检查配置。
|
||||
func keyAuthError(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrKeyUsed):
|
||||
return `{"error":"密钥已使用(一次性密钥只能用一次)"}`
|
||||
case errors.Is(err, repo.ErrKeyExpired):
|
||||
return `{"error":"密钥已过期"}`
|
||||
default:
|
||||
return `{"error":"密钥无效"}`
|
||||
}
|
||||
}
|
||||
|
||||
// AgentAuth 验证 Agent 身份,支持两种凭证:
|
||||
//
|
||||
// Authorization: Bearer <agent_key_token> —— 密钥认证(推荐)
|
||||
// X-Agent-Name + X-Agent-Secret —— 旧的 name/secret 方式(兼容保留)
|
||||
//
|
||||
// 用户密钥(user_keys)不接受:两类密钥共享 token 命名空间但走各自的验证表,
|
||||
// 因此用用户密钥调 Agent 接口只会得到「密钥无效」。
|
||||
func AgentAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if token := bearerToken(r); token != "" {
|
||||
agentName, err := repo.VerifyAgentKey(r.Context(), token)
|
||||
if err != nil {
|
||||
http.Error(w, keyAuthError(err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if agentName == "" {
|
||||
// 密钥有效但尚未绑定 Agent:注册接口会用请求里的 name 落定它,
|
||||
// 其余接口无法确定调用者身份,只能拒。
|
||||
http.Error(w, `{"error":"密钥尚未绑定 Agent,请先调用 /agent/register 完成注册"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
repo.HeartbeatAgent(r.Context(), agentName)
|
||||
ctx := context.WithValue(r.Context(), AgentNameKey, agentName)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
agentName := r.Header.Get("X-Agent-Name")
|
||||
agentSecret := r.Header.Get("X-Agent-Secret")
|
||||
if agentName == "" || agentSecret == "" {
|
||||
http.Error(w, `{"error":"Missing Authorization: Bearer <key> or X-Agent-Name/X-Agent-Secret header"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := repo.VerifyAgent(r.Context(), agentName, agentSecret)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"Invalid credentials"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
repo.HeartbeatAgent(r.Context(), agent.Name)
|
||||
ctx := context.WithValue(r.Context(), AgentNameKey, agent.Name)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// GetAgentName 从 context 中获取 agent_name
|
||||
func GetAgentName(r *http.Request) string {
|
||||
if v := r.Context().Value(AgentNameKey); v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
161
server/internal/middleware/user.go
Normal file
161
server/internal/middleware/user.go
Normal file
@ -0,0 +1,161 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/config"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
const UserKey contextKey = "auth_user"
|
||||
|
||||
// SetSessionCookie 写入登录 Cookie
|
||||
func SetSessionCookie(w http.ResponseWriter, token string, maxAge int) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: config.C.CookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: config.C.SecureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: maxAge,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearSessionCookie 清除登录 Cookie
|
||||
func ClearSessionCookie(w http.ResponseWriter) {
|
||||
SetSessionCookie(w, "", -1)
|
||||
}
|
||||
|
||||
// SessionToken 从请求中取出登录令牌
|
||||
func SessionToken(r *http.Request) string {
|
||||
c, err := r.Cookie(config.C.CookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// QueryToken 从 ?access_token= 取出令牌。
|
||||
//
|
||||
// 仅为浏览器 EventSource 存在:它不支持自定义请求头,因此订阅 SSE 时
|
||||
// 除了 Cookie 就只剩 query 一条路。代价是令牌会进访问日志,
|
||||
// 所以只在 SSE 端点启用,其余接口一律要求 Authorization 头。
|
||||
func QueryToken(r *http.Request) string {
|
||||
return strings.TrimSpace(r.URL.Query().Get("access_token"))
|
||||
}
|
||||
|
||||
// UserAuth 校验人类用户登录态,把 *models.User 注入 context。
|
||||
// 支持两种凭证:浏览器 Cookie,或 Authorization: Bearer <user_key_token>(第三方客户端)。
|
||||
func UserAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := resolve(r)
|
||||
if err != nil {
|
||||
// 只有 Cookie 路径才清 Cookie;密钥认证失败不应频带浏览器会话
|
||||
if bearerToken(r) == "" {
|
||||
ClearSessionCookie(w)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"not authenticated"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), UserKey, u)))
|
||||
})
|
||||
}
|
||||
|
||||
// AdminOnly 叠在 UserAuth 之后,要求 role = admin
|
||||
func AdminOnly(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u := GetUser(r)
|
||||
if u == nil || !u.IsAdmin() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"error":"admin only"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// UserAuthAllowQueryToken 与 UserAuth 相同,但额外接受 ?access_token=。
|
||||
//
|
||||
// 只给那些【由浏览器直接发起、无法设置请求头】的端点用(附件下载的 <a download>)。
|
||||
// URL 里的令牌会进访问日志与 Referer,所以不能全局开启。
|
||||
func UserAuthAllowQueryToken(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := resolve(r)
|
||||
if err != nil {
|
||||
if token := QueryToken(r); token != "" {
|
||||
if ku, kErr := repo.VerifyUserKey(r.Context(), token); kErr == nil {
|
||||
u, err = ku, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"not authenticated"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), UserKey, u)))
|
||||
})
|
||||
}
|
||||
|
||||
// GetUser 从 context 取登录用户;未登录返回 nil
|
||||
func GetUser(r *http.Request) *models.User {
|
||||
if v := r.Context().Value(UserKey); v != nil {
|
||||
if u, ok := v.(*models.User); ok {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserName 便捷取登录用户名
|
||||
func GetUserName(r *http.Request) string {
|
||||
if u := GetUser(r); u != nil {
|
||||
return u.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// OptionalUser 解析登录态但不拦截(SSE 等需要区分匿名/登录的场景)
|
||||
func OptionalUser(r *http.Request) *models.User {
|
||||
u, err := resolve(r)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// OptionalUserWithQuery 在 resolve 的三种凭证之外额外接受 ?access_token=。
|
||||
// 仅 SSE 用:浏览器 EventSource 无法带自定义头。
|
||||
func OptionalUserWithQuery(r *http.Request) *models.User {
|
||||
if u := OptionalUser(r); u != nil {
|
||||
return u
|
||||
}
|
||||
if token := QueryToken(r); token != "" {
|
||||
if u, err := repo.VerifyUserKey(r.Context(), token); err == nil {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolve 解析调用者身份:Cookie 优先,其次 Bearer 用户密钥。
|
||||
//
|
||||
// 用户密钥只能走到这里(/me/* 与会话级接口),Agent 密钥只能走 AgentAuth,
|
||||
// 两者各自查自己的表,因此拿 Agent 密钥读人类邮箱会得到 not authenticated。
|
||||
func resolve(r *http.Request) (*models.User, error) {
|
||||
if token := SessionToken(r); token != "" {
|
||||
return repo.ResolveUserSession(r.Context(), token)
|
||||
}
|
||||
if token := bearerToken(r); token != "" {
|
||||
return repo.VerifyUserKey(r.Context(), token)
|
||||
}
|
||||
return nil, repo.ErrSessionInvalid
|
||||
}
|
||||
Reference in New Issue
Block a user