feat(external_imap): 强化 OAuth 外部邮箱授权校验并完善 Outlook 文案
- OAuth 授权改为以服务商返回的真实邮箱为准,补充 ID Token 解析与邮箱一致性校验,避免默认落到本地邮箱。 - 为 Gmail 和 Microsoft 365 / Outlook OAuth 增加必要的 OIDC scope,并补充相关测试覆盖。 - Web 端新增 OAuth 授权弹窗,支持填写外部邮箱与存储模式;同步更新账号展示信息。 - 同步修正 README 与部署说明中的 Outlook 表述。
This commit is contained in:
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/emersion/go-sasl"
|
||||
smtpclient "github.com/emersion/go-smtp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func newTestApp(t *testing.T) *App {
|
||||
@@ -503,6 +504,102 @@ func TestExternalIMAPRejectsPrivateHostsByDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalIMAPOAuthStateDoesNotDefaultToLocalMailbox(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminEmail: "admin@lanqin.local",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
ExternalIMAPSecretKey: "test-secret",
|
||||
ExternalIMAPOutlookClientID: "client-id",
|
||||
ExternalIMAPOutlookClientSecret: "client-secret",
|
||||
})
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d", code)
|
||||
}
|
||||
_, mb := defaultAdminUserAndMailbox(t, a)
|
||||
|
||||
var start struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if code := admin.do("POST", "/api/me/external-imap-oauth/outlook/start", map[string]any{"mailboxId": mb.ID, "storageMode": "local", "syncReadState": true, "enabled": true}, &start); code != http.StatusOK {
|
||||
t.Fatalf("start oauth code=%d url=%q", code, start.URL)
|
||||
}
|
||||
stateValue := mustOAuthStateFromURL(t, start.URL)
|
||||
state, err := a.decryptExternalIMAPOAuthState(stateValue)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Email != "" {
|
||||
t.Fatalf("oauth state defaulted to local mailbox email: %q", state.Email)
|
||||
}
|
||||
|
||||
if code := admin.do("POST", "/api/me/external-imap-oauth/outlook/start", map[string]any{"mailboxId": mb.ID, "email": "User@Example.COM", "storageMode": "remote", "syncReadState": true, "enabled": true}, &start); code != http.StatusOK {
|
||||
t.Fatalf("start oauth with email code=%d url=%q", code, start.URL)
|
||||
}
|
||||
stateValue = mustOAuthStateFromURL(t, start.URL)
|
||||
state, err = a.decryptExternalIMAPOAuthState(stateValue)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Email != "user@example.com" {
|
||||
t.Fatalf("oauth state did not preserve requested external email, got %q", state.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalIMAPOAuthEmailFromIDToken(t *testing.T) {
|
||||
token := (&oauth2.Token{AccessToken: "access"}).WithExtra(map[string]any{
|
||||
"id_token": testIDToken(map[string]any{"preferred_username": "User@Example.COM"}),
|
||||
})
|
||||
email, err := externalIMAPOAuthEmail(externalIMAPOAuthOutlook, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if email != "user@example.com" {
|
||||
t.Fatalf("unexpected outlook oauth email %q", email)
|
||||
}
|
||||
|
||||
token = (&oauth2.Token{AccessToken: "access"}).WithExtra(map[string]any{
|
||||
"id_token": testIDToken(map[string]any{"email": "Person@Gmail.COM"}),
|
||||
})
|
||||
email, err = externalIMAPOAuthEmail(externalIMAPOAuthGmail, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if email != "person@gmail.com" {
|
||||
t.Fatalf("unexpected gmail oauth email %q", email)
|
||||
}
|
||||
}
|
||||
|
||||
func mustOAuthStateFromURL(t *testing.T, rawURL string) string {
|
||||
t.Helper()
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := u.Query().Get("state")
|
||||
if state == "" {
|
||||
t.Fatalf("oauth url missing state: %s", rawURL)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func testIDToken(claims map[string]any) string {
|
||||
header, _ := json.Marshal(map[string]any{"alg": "none"})
|
||||
payload, _ := json.Marshal(claims)
|
||||
return base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + "."
|
||||
}
|
||||
|
||||
func TestExternalIMAPAccountOwnershipIsolation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
|
||||
@@ -418,9 +418,10 @@ func (a *App) handleStartExternalIMAPOAuth(w http.ResponseWriter, r *http.Reques
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(req.Email)
|
||||
if email == "" {
|
||||
email = mb.Address
|
||||
email := normalizeEmail(req.Email)
|
||||
if email != "" && !strings.Contains(email, "@") {
|
||||
badRequest(w, errors.New("invalid oauth email"))
|
||||
return
|
||||
}
|
||||
req.StorageMode = strings.ToLower(strings.TrimSpace(req.StorageMode))
|
||||
if req.StorageMode == "" {
|
||||
@@ -511,9 +512,18 @@ func (a *App) handleExternalIMAPOAuthCallback(w http.ResponseWriter, r *http.Req
|
||||
if !token.Expiry.IsZero() {
|
||||
expiry = token.Expiry.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
authorizedEmail, err := externalIMAPOAuthEmail(provider, token)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if state.Email != "" && !strings.EqualFold(normalizeEmail(state.Email), authorizedEmail) {
|
||||
respondError(w, http.StatusBadRequest, "oauth account email does not match requested external email")
|
||||
return
|
||||
}
|
||||
id := newID("ximap")
|
||||
_, err = a.db.ExecContext(r.Context(), `INSERT INTO external_imap_accounts(id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,auth_mode,oauth_provider,oauth_email,oauth_access_token_ciphertext,oauth_refresh_token_ciphertext,oauth_expiry,storage_mode,sync_read_state,enabled,last_status,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, state.UserID, state.MailboxID, name, profile.Host, profile.Port, externalIMAPTLS, state.Email, "", externalIMAPAuthOAuth2, provider, state.Email, accessCipher, refreshCipher, expiry, state.StorageMode, boolInt(state.SyncReadState), boolInt(state.Enabled), "idle", now, now)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, state.UserID, state.MailboxID, name, profile.Host, profile.Port, externalIMAPTLS, authorizedEmail, "", externalIMAPAuthOAuth2, provider, authorizedEmail, accessCipher, refreshCipher, expiry, state.StorageMode, boolInt(state.SyncReadState), boolInt(state.Enabled), "idle", now, now)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save oauth account")
|
||||
return
|
||||
@@ -871,7 +881,7 @@ func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, external
|
||||
ClientID: a.cfg.ExternalIMAPGmailClientID,
|
||||
ClientSecret: a.cfg.ExternalIMAPGmailClientSecret,
|
||||
RedirectURL: callback,
|
||||
Scopes: []string{"https://mail.google.com/"},
|
||||
Scopes: []string{"openid", "email", "profile", "https://mail.google.com/"},
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
@@ -885,7 +895,7 @@ func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, external
|
||||
ClientID: a.cfg.ExternalIMAPOutlookClientID,
|
||||
ClientSecret: a.cfg.ExternalIMAPOutlookClientSecret,
|
||||
RedirectURL: callback,
|
||||
Scopes: []string{"offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All"},
|
||||
Scopes: []string{"openid", "email", "profile", "offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All"},
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||
TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
@@ -896,6 +906,45 @@ func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, external
|
||||
}
|
||||
}
|
||||
|
||||
func externalIMAPOAuthEmail(provider string, token *oauth2.Token) (string, error) {
|
||||
if token == nil {
|
||||
return "", errors.New("oauth token is missing")
|
||||
}
|
||||
idToken, _ := token.Extra("id_token").(string)
|
||||
claims, err := externalIMAPOIDCClaims(idToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fields := []string{"email"}
|
||||
if provider == externalIMAPOAuthOutlook {
|
||||
fields = []string{"email", "preferred_username", "upn"}
|
||||
}
|
||||
for _, field := range fields {
|
||||
value, _ := claims[field].(string)
|
||||
email := normalizeEmail(value)
|
||||
if email != "" && strings.Contains(email, "@") {
|
||||
return email, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("oauth provider did not return an email address")
|
||||
}
|
||||
|
||||
func externalIMAPOIDCClaims(idToken string) (map[string]any, error) {
|
||||
parts := strings.Split(idToken, ".")
|
||||
if len(parts) < 2 {
|
||||
return nil, errors.New("oauth provider did not return an id token")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid oauth id token")
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil, errors.New("invalid oauth id token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (a *App) encryptExternalIMAPOAuthState(state externalIMAPOAuthState) (string, error) {
|
||||
raw, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user