Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applie
| `VOCAT_ADDR` | `0.0.0.0:7575` | HTTP listen address. |
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | SQLite database path. |
| `VOCAT_SESSION_TTL` | `24h` | Authentication session lifetime. |
| `VOCAT_SECURE_COOKIES` | `false` | Marks session cookies as secure when HTTPS is used. |
| `VOCAT_SECURE_COOKIES` | `false` | Optional force-Secure override for all entries; HTTP/HTTPS cookies otherwise adapt automatically. |
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown timeout. |
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | Maximum API request body size. |
| `VOCAT_MCP_ENABLED` | `true` | Serve the MCP endpoint at `/mcp` for AI agents. Set `false` to disable it. |
Expand Down Expand Up @@ -292,7 +292,7 @@ administrators, including accounts sharing the bound account's email address.
### Pocket ID

1. Use an HTTPS origin for VoCat, such as `https://vocat.example.com`, and set
`VOCAT_SECURE_COOKIES=true`. In the SSO settings choose **Pocket ID**. The
the HTTPS entry as the public origin. Cookie security is selected automatically for each browser entry; `VOCAT_SECURE_COOKIES=true` is only an optional global force-Secure override. In the SSO settings choose **Pocket ID**. The
public origin initially uses the current browser address; edit the visible
**VoCat public origin** field if necessary. The compact callback URL and Copy
button appear immediately, so the URL can be copied
Expand Down Expand Up @@ -348,6 +348,18 @@ The default Token authentication is `client_secret_post` and PKCE is S256.
Disable PKCE only for a provider that lacks PKCE support.
Generic OAuth2 does not perform OIDC ID Token verification.

### Multiple browser entries for SSO

One saved provider and account binding can serve the configured HTTPS public origin and explicit local/private IP entries over HTTP or HTTPS. Register each complete callback URL (`/api/auth/sso/callback`) with the identity provider. Authorization and token exchange use the same callback frozen at flow creation; callbacks on a different entry are rejected. HTTP is accepted only for localhost, loopback and literal private IP addresses, not arbitrary public HTTP hosts.

IPv6 callback hosts use the browser's compressed spelling, including hexadecimal IPv4-mapped addresses. Register the callback shown in settings with the identity provider. Existing configurations using an expanded IPv6 spelling are recognized automatically, but the provider must accept the normalized callback URL.

Direct TLS, the configured HTTPS public origin behind a proxy, and trusted `X-Forwarded-Proto` select Secure cookies automatically. Untrusted forwarded headers are ignored. Proxies must preserve Host. Password sessions use their request transport, the explicit override, and same-authority HTTPS Origin/Referer hints that can only upgrade Cookie protection. These browser hints do not authorize requests, change network access policy, or select callback destinations.

A session persists its Secure-cookie requirement at sign-in and can only upgrade it on later HTTPS requests, including when a missing or stale CSRF cookie must be replaced without scheme hints. Existing sessions keep working after the additive database migration; their previous transport cannot be inferred, so HTTPS users with an existing session should sign in again or make a refresh with a recognized HTTPS signal.

Saving or disabling SSO does not change password-client Cookie behavior. A proxy that removes all browser and transport scheme information must explicitly provide a trusted HTTPS signal. The configured-origin fallback is limited to SSO sign-in and SSO-session CSRF refreshes. Separate LAN HTTP entries remain usable without a process restart or configuration switch; remove a legacy force-Secure override when using both protocols. HTTP credentials travel without transport encryption.

### Network, credentials and sessions

VoCat's existing source-network policy also applies to SSO routes. Configuring
Expand Down
3 changes: 2 additions & 1 deletion cmd/vocat/bootstrap_admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"vocat/internal/store"
)

// TestBootstrapAdminOnlyInitializesAnEmptyDatabase preserves stored administrator credentials across later bootstrap attempts.
func TestBootstrapAdminOnlyInitializesAnEmptyDatabase(t *testing.T) {
path := filepath.Join(t.TempDir(), "vocat.db")
withBootstrapStdin(t, "first-secure-password\n", func() {
Expand All @@ -32,7 +33,7 @@ func TestBootstrapAdminOnlyInitializesAnEmptyDatabase(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := service.Login(context.Background(), "admin", "first-secure-password"); err != nil {
if _, err := service.Login(context.Background(), "admin", "first-secure-password", false); err != nil {
t.Fatalf("initial password was overwritten: %v", err)
}
}
Expand Down
20 changes: 16 additions & 4 deletions docs/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT
| `VOCAT_ADDR` | `0.0.0.0:7575` | HTTP 监听地址。 |
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | SQLite 数据库路径。 |
| `VOCAT_SESSION_TTL` | `24h` | 鉴权会话有效期。 |
| `VOCAT_SECURE_COOKIES` | `false` | 在使用 HTTPS 时将会话 Cookie 标记为安全。 |
| `VOCAT_SECURE_COOKIES` | `false` | 可选:强制所有入口使用安全 Cookie;默认按 HTTP/HTTPS 入口自动适配。 |
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | 优雅关闭超时时间。 |
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | API 请求体最大字节数。 |
| `VOCAT_REPO` | `MengMengCode/VoCat` | 自更新器使用的受信任 GitHub 仓库,格式为 `owner/name`。 |
Expand All @@ -236,7 +236,7 @@ VoCat 支持 Pocket ID(OpenID Connect)、GitHub.com OAuth App 和 Generic OA
### 配置 Pocket ID

1. 为 VoCat 配置 HTTPS 根路径入口,例如 `https://vocat.example.com`,并设置
`VOCAT_SECURE_COOKIES=true`。在 SSO 设置选择 **Pocket ID**,公开入口初始使用当前
HTTPS 公开地址。Cookie 会按访问入口自动适配,无需设置 `VOCAT_SECURE_COOKIES`;该环境变量只用于强制所有入口使用安全 Cookie。在 SSO 设置选择 **Pocket ID**,公开入口初始使用当前
浏览器访问地址,需要调整时直接编辑**VoCat 公开地址**。页面立即显示紧凑的回调 URL 和复制按钮,
无需先保存或填写客户端凭据:
`https://vocat.example.com/api/auth/sso/callback`。
Expand Down Expand Up @@ -274,6 +274,18 @@ UserInfo 通过 Bearer access token 请求;用户 ID
默认 PKCE 为 S256。只有确实不支持 PKCE 的旧服务才使用关闭选项。
Generic OAuth2 不提供 OIDC ID Token 验证。

### 同时使用域名和内网 IP 登录

同一份身份服务配置和账号绑定可用于已配置的 HTTPS 公开入口,以及 localhost、loopback 和明确的私网 IP 入口。请在身份服务登记每个入口的完整回调 URL(路径为 `/api/auth/sso/callback`)。程序按当前入口生成授权回调地址,并在 Token 交换时使用同一个地址;跨入口提交回调会被拒绝。普通公网 HTTP 地址仍不被允许。

IPv6 回调地址采用浏览器压缩后的写法,IPv4 映射 IPv6 地址也使用十六进制形式。请在身份服务中登记设置页面显示的回调地址。已有的展开形式配置会自动识别,但身份服务必须允许规范化后的回调地址。

直接 TLS、已配置的 HTTPS 反代入口或可信的 `X-Forwarded-Proto` 会自动启用安全 Cookie;未受信任的转发头不会参与判断,反代应保留 Host。密码会话依据请求传输方式、显式强制设置以及同源 HTTPS Origin/Referer 信息选择 Cookie 属性;浏览器信息只可加强 Cookie 保护,不参与鉴权、网络访问控制或回调地址选择。

会话在登录时保存安全 Cookie 要求,之后识别到 HTTPS 时只会加强保护;即使 CSRF Cookie 丢失或失效、刷新请求又没有协议提示,补发时也会保留这个要求。数据库升级会保留已有会话;旧会话无法反推出此前的传输协议,HTTPS 用户重新登录一次或进行一次带有可识别 HTTPS 信息的刷新后,即可保存该要求。

保存或关闭 SSO 不会改变密码客户端的行为。若代理剥除全部浏览器与传输协议信息,需要显式提供可信 HTTPS 标识。公开地址的推断仅用于 SSO 登录和 SSO 会话的 CSRF 刷新。独立的内网 HTTP 入口无需切换配置或重启。混合使用两种协议时请移除旧的全局 `VOCAT_SECURE_COOKIES=true` 强制设置。HTTP 入口的登录凭据会以明文传输。

### 网络、凭据与会话

SSO 路由继续受现有来源网络策略限制,启用 SSO 不会将内网入口自动开放到公网。
Expand All @@ -291,8 +303,8 @@ link-local、云元数据和其他特殊用途地址。私有 CA 应安装到主
许可相互独立。

当前 Compose 使用 host network,应在实际容器内验证 DNS、路由和 CA,不假定桥接网络
service name 可用。反代入口必须与已保存的公开 origin 一致;回调地址不从任意转发 Host
头推导。首期只支持根路径部署。本地 Vite 开发时统一使用一个 loopback origin,例如
service name 可用。公开域名反代入口应与已保存的公开 origin 一致;同时允许显式私网 IP 入口,
不从任意转发 Host 头推导回调地址。首期只支持根路径部署。本地 Vite 开发时统一使用一个 loopback origin,例如
`http://127.0.0.1:5173`,并在身份平台登记同一回调地址;IdP 出站网络例外仅用于测试 fixture。

主 Session 和 CSRF Cookie 保持 `SameSite=Strict`,短期授权 Cookie 使用
Expand Down
24 changes: 13 additions & 11 deletions internal/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,12 @@ type Credentials struct {
}

type AuthenticatedSession struct {
Principal Principal
ExpiresAt time.Time
LoginMethod string
tokenHash []byte
csrfHash []byte
Principal Principal
ExpiresAt time.Time
LoginMethod string
SecureCookies bool
tokenHash []byte
csrfHash []byte
}

// TokenHash returns a copy of the session digest for atomic authorization checks.
Expand Down Expand Up @@ -162,7 +163,7 @@ func (s *Service) ResetAdminCredentials(ctx context.Context, username string, pa
}

// Login verifies the local password and persists fresh session and CSRF credentials before returning them.
func (s *Service) Login(ctx context.Context, username string, password string) (Credentials, error) {
func (s *Service) Login(ctx context.Context, username string, password string, secureCookies bool) (Credentials, error) {
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
if errors.Is(err, store.ErrNotFound) {
_ = comparePassword(s.dummyHash, password)
Expand All @@ -182,7 +183,7 @@ func (s *Service) Login(ctx context.Context, username string, password string) (
if err != nil {
return Credentials{}, err
}
if err := s.store.CreateSession(ctx, admin.ID, hashToken(credentials.SessionToken), hashToken(credentials.CSRFToken), credentials.ExpiresAt); err != nil {
if err := s.store.CreateSession(ctx, admin.ID, hashToken(credentials.SessionToken), hashToken(credentials.CSRFToken), credentials.ExpiresAt, secureCookies); err != nil {
return Credentials{}, err
}
return credentials, nil
Expand Down Expand Up @@ -231,10 +232,11 @@ func (s *Service) Authenticate(ctx context.Context, sessionToken string) (Authen
ID: session.Admin.ID,
Username: session.Admin.Username,
},
ExpiresAt: session.ExpiresAt,
LoginMethod: session.LoginMethod,
tokenHash: tokenHash,
csrfHash: session.CSRFHash,
ExpiresAt: session.ExpiresAt,
LoginMethod: session.LoginMethod,
SecureCookies: session.SecureCookies,
tokenHash: tokenHash,
csrfHash: session.CSRFHash,
}, nil
}

Expand Down
28 changes: 17 additions & 11 deletions internal/auth/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,15 @@ func newTestService(t *testing.T) *Service {
return service
}

// TestLoginAuthenticateCSRFAndLogout checks the complete local session lifecycle and rejection of invalid credentials.
func TestLoginAuthenticateCSRFAndLogout(t *testing.T) {
ctx := context.Background()
service := newTestService(t)

if _, err := service.Login(ctx, "admin", "wrong-password"); !errors.Is(err, ErrInvalidCredentials) {
if _, err := service.Login(ctx, "admin", "wrong-password", false); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("Login() error = %v, want ErrInvalidCredentials", err)
}
credentials, err := service.Login(ctx, "admin", "correct-password")
credentials, err := service.Login(ctx, "admin", "correct-password", false)
if err != nil {
t.Fatalf("Login() error = %v", err)
}
Expand Down Expand Up @@ -79,10 +80,11 @@ func TestLoginAuthenticateCSRFAndLogout(t *testing.T) {
}
}

// TestEnsureAdminRevokesSessionOnPasswordChange verifies that replacing administrator credentials revokes existing sessions.
func TestEnsureAdminRevokesSessionOnPasswordChange(t *testing.T) {
ctx := context.Background()
service := newTestService(t)
credentials, err := service.Login(ctx, "admin", "correct-password")
credentials, err := service.Login(ctx, "admin", "correct-password", false)
if err != nil {
t.Fatal(err)
}
Expand All @@ -93,26 +95,27 @@ func TestEnsureAdminRevokesSessionOnPasswordChange(t *testing.T) {
if _, err := service.Authenticate(ctx, credentials.SessionToken); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("old session error = %v, want ErrUnauthorized", err)
}
if _, err := service.Login(ctx, "admin", "new-password"); err != nil {
if _, err := service.Login(ctx, "admin", "new-password", false); err != nil {
t.Fatalf("login with new password: %v", err)
}
}

// TestResetAdminCredentialsChangesUsernameAndPasswordWithoutOldPassword checks administrative credential recovery and rejection of superseded credentials.
func TestResetAdminCredentialsChangesUsernameAndPasswordWithoutOldPassword(t *testing.T) {
ctx := context.Background()
service := newTestService(t)
credentials, err := service.Login(ctx, "admin", "correct-password")
credentials, err := service.Login(ctx, "admin", "correct-password", false)
if err != nil {
t.Fatal(err)
}

if err := service.ResetAdminCredentials(ctx, "new-admin", "replacement-password"); err != nil {
t.Fatalf("ResetAdminCredentials() error = %v", err)
}
if _, err := service.Login(ctx, "admin", "correct-password"); !errors.Is(err, ErrInvalidCredentials) {
if _, err := service.Login(ctx, "admin", "correct-password", false); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("old credentials error = %v, want ErrInvalidCredentials", err)
}
if _, err := service.Login(ctx, "new-admin", "replacement-password"); err != nil {
if _, err := service.Login(ctx, "new-admin", "replacement-password", false); err != nil {
t.Fatalf("new credentials login error = %v", err)
}
if _, err := service.Authenticate(ctx, credentials.SessionToken); !errors.Is(err, ErrUnauthorized) {
Expand All @@ -139,32 +142,35 @@ func TestResetAdminCredentialsValidatesInput(t *testing.T) {
}
}

// TestResetAdminCredentialsAcceptsPasswordsWithoutComplexityRules keeps administrative recovery independent of password complexity rules.
func TestResetAdminCredentialsAcceptsPasswordsWithoutComplexityRules(t *testing.T) {
ctx := context.Background()
for _, password := range []string{"1", strings.Repeat("x", 256)} {
service := newTestService(t)
if err := service.ResetAdminCredentials(ctx, "admin", password); err != nil {
t.Fatalf("ResetAdminCredentials(%d-byte password) error = %v", len(password), err)
}
if _, err := service.Login(ctx, "admin", password); err != nil {
if _, err := service.Login(ctx, "admin", password, false); err != nil {
t.Fatalf("Login(%d-byte password) error = %v", len(password), err)
}
}
}

// TestChangePasswordAcceptsPasswordsWithoutComplexityRules keeps authenticated password changes independent of complexity rules.
func TestChangePasswordAcceptsPasswordsWithoutComplexityRules(t *testing.T) {
ctx := context.Background()
for _, password := range []string{"1", strings.Repeat("long-password-", 32)} {
service := newTestService(t)
if err := service.ChangePassword(ctx, "admin", "correct-password", password); err != nil {
t.Fatalf("ChangePassword(%d-byte password) error = %v", len(password), err)
}
if _, err := service.Login(ctx, "admin", password); err != nil {
if _, err := service.Login(ctx, "admin", password, false); err != nil {
t.Fatalf("Login(%d-byte password) error = %v", len(password), err)
}
}
}

// TestEnsureAdminIfMissingDoesNotOverwriteChangedPassword preserves an existing administrator when startup credentials differ.
func TestEnsureAdminIfMissingDoesNotOverwriteChangedPassword(t *testing.T) {
ctx := context.Background()
service := newTestService(t)
Expand All @@ -178,10 +184,10 @@ func TestEnsureAdminIfMissingDoesNotOverwriteChangedPassword(t *testing.T) {
if created {
t.Fatal("existing administrator was reported as newly created")
}
if _, err := service.Login(ctx, "admin", "changed-password"); err != nil {
if _, err := service.Login(ctx, "admin", "changed-password", false); err != nil {
t.Fatalf("database password was overwritten: %v", err)
}
if _, err := service.Login(ctx, "admin", "stale-config-password"); !errors.Is(err, ErrInvalidCredentials) {
if _, err := service.Login(ctx, "admin", "stale-config-password", false); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("stale configured password became active: %v", err)
}
}
2 changes: 1 addition & 1 deletion internal/auth/sso_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func TestPreparedCredentialsRequireAtomicCommit(t *testing.T) {
func TestAuthenticatedSessionDigestAccessorPreservesSessionAndCSRF(t *testing.T) {
service := newTestService(t)
ctx := context.Background()
credentials, err := service.Login(ctx, "admin", "correct-password")
credentials, err := service.Login(ctx, "admin", "correct-password", false)
if err != nil {
t.Fatal(err)
}
Expand Down
Loading
Loading