mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* feat(auth): add revocable API tokens (bot/service auth) Add long-lived, revocable API tokens so headless clients (the introspection MCP tool, bots, CI) can authenticate without a password. Presented as "Authorization: Bearer <token>", a token authenticates as a specific user, inheriting that user's role and permissions. - migration 018 + dedicated api_tokens table (kept separate from sessions so bulk logout and the per-user session cap never touch these); only the SHA-256 hash is stored, raw token shown once at creation - auth.ResolveTokenHash: one shared bearer resolver that both AuthMiddleware and adminAuthMiddleware now call. Sessions are matched first so existing login behavior is unchanged; API tokens are a fallback only on session miss. A DB outage is returned wrapped, never mistaken for a bad token. - `server token create|list|revoke` CLI: mints directly against the DB with no HTTP and no login — the password-free bootstrap path - tests: resolver (8 cases incl. outage-not-fallthrough), db queries (6), api middleware integration (valid + revoked token) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tools): add owncord-introspect MCP server A local MCP dev tool that lets Claude Code introspect a running OwnCord instance: read its logs, query any REST endpoint, and tail the desktop client's log file. It is a thin wrapper over the existing API plus the client log — no new product surface. - tools/mcp-introspect/index.mjs (Node/ESM, one dep: @modelcontextprotocol/sdk) exposes api_request (full read-write passthrough), server_logs (admin SSE ring-buffer stream), client_logs (reads the desktop log file) - authenticates with an API token (OWNCORD_API_TOKEN); pins the self-signed cert and skips hostname checks (the cert has no SAN) - registered in .mcp.json (secret-free ${OWNCORD_API_TOKEN}) - un-ignore tools/mcp-introspect/ so this shared dev tool is committed, while tools/livekit-server.exe and node_modules stay ignored - docs/mcp-introspect.md: how it works, tool reference, setup, troubleshooting Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dependencies): update and add various crate versions in Cargo.lock * feat(admin): manage API tokens from the admin panel Add Owner-gated HTTP endpoints and a UI card to create, list, and revoke API tokens from the web admin panel. Previously only the `server token` CLI could manage them, which requires shell access to the host. - POST|GET|DELETE /admin/api/tokens in admin/handlers_tokens.go, wired in admin/api.go. All three are Owner-only (ownerOnlyMiddleware, like backups/updates): an HTTP token-mint endpoint is a network-reachable credential-minting surface, and API tokens deliberately survive password change + bulk logout, so a hijacked admin session must not mint one. - Reuses the same db.*APIToken calls as the CLI; create sources the actor from request context (audits who clicked, not the bound user); the raw token is returned once in the 201 body, never stored. - Add json tags to db.APITokenListItem for snake_case wire consistency. - Admin panel: "API Tokens" nav item + create modal, show-once reveal, revoke confirm in admin/static/index.html. - Tests: 7 in admin/api_test.go (+api_tokens table in the in-memory schema). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: modernize to Go 1.26 idioms + enable modernize linter Apply `golangci-lint modernize` autofixes across the server and enable the linter in .golangci.yml so these stop re-accumulating (they built up only because modernize was never in the config). Production code: slices.Contains for hand-rolled membership loops (api router, ws origin, db/account, plugin manifest); strings.SplitSeq for allocation-free line/segment iteration (db/migrate, updater, livekit_proxy); strings.Cut (config); fmt.Appendf (dm_handler); min() (event_pruner); any (ws client). Tests: range-over-int, t.Context(), WaitGroup.Go, slices.Sort, maps.Copy, new(expr), interface{}->any. - plugin/manifest.go parent-traversal check applied by hand: modernize skipped it (two conflicting rewrites); used the slices.Contains form. - Removed the now-dead ptr() test helper after newexpr inlined its callers. - Dropped dangling sort imports left by the sort.Slice->slices.Sort rewrite. No behavior change. All four tag variants build, full test suite is green, and golangci-lint (with modernize enabled) reports 0 issues. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
427 lines
14 KiB
Go
427 lines
14 KiB
Go
package auth_test
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/owncord/server/auth"
|
|
"github.com/owncord/server/db"
|
|
)
|
|
|
|
// ─── ExtractBearerToken ───────────────────────────────────────────────────────
|
|
|
|
func TestExtractBearerToken_ValidHeader(t *testing.T) {
|
|
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Authorization", "Bearer mytoken123")
|
|
|
|
token, ok := auth.ExtractBearerToken(r)
|
|
|
|
if !ok {
|
|
t.Fatal("ExtractBearerToken() ok = false, want true")
|
|
}
|
|
if token != "mytoken123" {
|
|
t.Errorf("ExtractBearerToken() token = %q, want %q", token, "mytoken123")
|
|
}
|
|
}
|
|
|
|
func TestExtractBearerToken_MissingHeader(t *testing.T) {
|
|
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
|
|
|
token, ok := auth.ExtractBearerToken(r)
|
|
|
|
if ok {
|
|
t.Error("ExtractBearerToken() ok = true with no Authorization header, want false")
|
|
}
|
|
if token != "" {
|
|
t.Errorf("ExtractBearerToken() token = %q, want empty string", token)
|
|
}
|
|
}
|
|
|
|
func TestExtractBearerToken_EmptyHeaderValue(t *testing.T) {
|
|
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Authorization", "")
|
|
|
|
_, ok := auth.ExtractBearerToken(r)
|
|
|
|
if ok {
|
|
t.Error("ExtractBearerToken() ok = true for empty header value, want false")
|
|
}
|
|
}
|
|
|
|
func TestExtractBearerToken_WrongScheme(t *testing.T) {
|
|
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
|
|
|
|
_, ok := auth.ExtractBearerToken(r)
|
|
|
|
if ok {
|
|
t.Error("ExtractBearerToken() ok = true for Basic scheme, want false")
|
|
}
|
|
}
|
|
|
|
func TestExtractBearerToken_BearerCaseInsensitive(t *testing.T) {
|
|
cases := []string{
|
|
"BEARER mytoken",
|
|
"bearer mytoken",
|
|
"Bearer mytoken",
|
|
"bEaReR mytoken",
|
|
}
|
|
for _, authHeader := range cases {
|
|
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Authorization", authHeader)
|
|
|
|
token, ok := auth.ExtractBearerToken(r)
|
|
|
|
if !ok {
|
|
t.Errorf("ExtractBearerToken() ok = false for header %q, want true", authHeader)
|
|
}
|
|
if token != "mytoken" {
|
|
t.Errorf("ExtractBearerToken() token = %q for header %q, want %q", token, authHeader, "mytoken")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtractBearerToken_BearerWithNoToken(t *testing.T) {
|
|
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Authorization", "Bearer ")
|
|
|
|
_, ok := auth.ExtractBearerToken(r)
|
|
|
|
if ok {
|
|
t.Error("ExtractBearerToken() ok = true for 'Bearer ' with empty token, want false")
|
|
}
|
|
}
|
|
|
|
func TestExtractBearerToken_OnlySchemeNoSpace(t *testing.T) {
|
|
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Authorization", "Bearer")
|
|
|
|
_, ok := auth.ExtractBearerToken(r)
|
|
|
|
if ok {
|
|
t.Error("ExtractBearerToken() ok = true for 'Bearer' with no space or token, want false")
|
|
}
|
|
}
|
|
|
|
func TestExtractBearerToken_TokenPreservesValue(t *testing.T) {
|
|
// Tokens can contain mixed-case, digits, hyphens, underscores, dots.
|
|
rawToken := "aB3-xY9_zZ0.qQ7"
|
|
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Authorization", "Bearer "+rawToken)
|
|
|
|
token, ok := auth.ExtractBearerToken(r)
|
|
|
|
if !ok {
|
|
t.Fatal("ExtractBearerToken() ok = false, want true")
|
|
}
|
|
if token != rawToken {
|
|
t.Errorf("ExtractBearerToken() token = %q, want %q", token, rawToken)
|
|
}
|
|
}
|
|
|
|
func TestExtractBearerToken_MultipleSpaces(t *testing.T) {
|
|
// SplitN with n=2 means "Bearer tok" splits into ["Bearer", " tok"].
|
|
// The implementation trims whitespace, so " mytoken" becomes "mytoken".
|
|
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Authorization", "Bearer mytoken")
|
|
|
|
token, ok := auth.ExtractBearerToken(r)
|
|
|
|
// The implementation applies TrimSpace to the extracted token,
|
|
// so the leading space from the double-space header is stripped.
|
|
if !ok {
|
|
t.Fatal("ExtractBearerToken() ok = false for double-space header, want true")
|
|
}
|
|
if token != "mytoken" {
|
|
t.Errorf("ExtractBearerToken() token = %q, want %q", token, "mytoken")
|
|
}
|
|
}
|
|
|
|
// ─── IsSessionExpired ─────────────────────────────────────────────────────────
|
|
|
|
func TestIsSessionExpired_FutureTimeNotExpired(t *testing.T) {
|
|
future := time.Now().UTC().Add(time.Hour)
|
|
expiresAt := future.Format("2006-01-02 15:04:05")
|
|
|
|
if auth.IsSessionExpired(expiresAt) {
|
|
t.Errorf("IsSessionExpired(%q) = true for future time, want false", expiresAt)
|
|
}
|
|
}
|
|
|
|
func TestIsSessionExpired_PastTimeExpired(t *testing.T) {
|
|
past := time.Now().UTC().Add(-time.Hour)
|
|
expiresAt := past.Format("2006-01-02 15:04:05")
|
|
|
|
if !auth.IsSessionExpired(expiresAt) {
|
|
t.Errorf("IsSessionExpired(%q) = false for past time, want true", expiresAt)
|
|
}
|
|
}
|
|
|
|
func TestIsSessionExpired_FutureTimeSQLiteFormat(t *testing.T) {
|
|
future := time.Now().UTC().Add(24 * time.Hour)
|
|
expiresAt := future.Format("2006-01-02 15:04:05")
|
|
|
|
if auth.IsSessionExpired(expiresAt) {
|
|
t.Errorf("IsSessionExpired(%q) = true for future SQLite-format time, want false", expiresAt)
|
|
}
|
|
}
|
|
|
|
func TestIsSessionExpired_PastTimeSQLiteFormat(t *testing.T) {
|
|
past := time.Now().UTC().Add(-24 * time.Hour)
|
|
expiresAt := past.Format("2006-01-02 15:04:05")
|
|
|
|
if !auth.IsSessionExpired(expiresAt) {
|
|
t.Errorf("IsSessionExpired(%q) = false for past SQLite-format time, want true", expiresAt)
|
|
}
|
|
}
|
|
|
|
func TestIsSessionExpired_FutureTimeISO8601Format(t *testing.T) {
|
|
future := time.Now().UTC().Add(time.Hour)
|
|
expiresAt := future.Format("2006-01-02T15:04:05Z")
|
|
|
|
if auth.IsSessionExpired(expiresAt) {
|
|
t.Errorf("IsSessionExpired(%q) = true for future ISO-8601 time, want false", expiresAt)
|
|
}
|
|
}
|
|
|
|
func TestIsSessionExpired_PastTimeISO8601Format(t *testing.T) {
|
|
past := time.Now().UTC().Add(-time.Hour)
|
|
expiresAt := past.Format("2006-01-02T15:04:05Z")
|
|
|
|
if !auth.IsSessionExpired(expiresAt) {
|
|
t.Errorf("IsSessionExpired(%q) = false for past ISO-8601 time, want true", expiresAt)
|
|
}
|
|
}
|
|
|
|
func TestIsSessionExpired_EmptyString(t *testing.T) {
|
|
// Unparseable — must treat as expired for safety.
|
|
if !auth.IsSessionExpired("") {
|
|
t.Error("IsSessionExpired(\"\") = false for empty string, want true (fail-safe)")
|
|
}
|
|
}
|
|
|
|
func TestIsSessionExpired_InvalidFormat(t *testing.T) {
|
|
cases := []string{
|
|
"not-a-date",
|
|
"2025/03/15 12:00:00",
|
|
"15-03-2025",
|
|
"2025-13-45T99:99:99Z", // out-of-range values
|
|
}
|
|
for _, s := range cases {
|
|
if !auth.IsSessionExpired(s) {
|
|
t.Errorf("IsSessionExpired(%q) = false for invalid format, want true (fail-safe)", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsSessionExpired_ExactlyNow(t *testing.T) {
|
|
// A timestamp one second in the past must always be expired.
|
|
justPast := time.Now().UTC().Add(-time.Second)
|
|
expiresAt := justPast.Format("2006-01-02 15:04:05")
|
|
|
|
if !auth.IsSessionExpired(expiresAt) {
|
|
t.Errorf("IsSessionExpired(%q) = false for just-past time, want true", expiresAt)
|
|
}
|
|
}
|
|
|
|
// ─── IsEffectivelyBanned ──────────────────────────────────────────────────────
|
|
|
|
func TestIsEffectivelyBanned_NotBanned(t *testing.T) {
|
|
u := &db.User{Banned: false}
|
|
if auth.IsEffectivelyBanned(u) {
|
|
t.Error("IsEffectivelyBanned(Banned=false) = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestIsEffectivelyBanned_BannedNilExpiry(t *testing.T) {
|
|
// Banned with no expiry — permanently banned.
|
|
u := &db.User{Banned: true, BanExpires: nil}
|
|
if !auth.IsEffectivelyBanned(u) {
|
|
t.Error("IsEffectivelyBanned(Banned=true, BanExpires=nil) = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestIsEffectivelyBanned_BannedFutureExpiry(t *testing.T) {
|
|
// Banned with an expiry in the future — still banned.
|
|
future := time.Now().UTC().Add(time.Hour).Format("2006-01-02 15:04:05")
|
|
u := &db.User{Banned: true, BanExpires: new(future)}
|
|
if !auth.IsEffectivelyBanned(u) {
|
|
t.Error("IsEffectivelyBanned(Banned=true, future expiry) = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestIsEffectivelyBanned_BannedPastExpiry(t *testing.T) {
|
|
// Banned but the ban expired in the past — should be treated as NOT banned.
|
|
past := time.Now().UTC().Add(-time.Hour).Format("2006-01-02 15:04:05")
|
|
u := &db.User{Banned: true, BanExpires: new(past)}
|
|
if auth.IsEffectivelyBanned(u) {
|
|
t.Error("IsEffectivelyBanned(Banned=true, past expiry) = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestIsEffectivelyBanned_BannedExpiredISO8601(t *testing.T) {
|
|
// ISO-8601 format for BanExpires past — should be treated as NOT banned.
|
|
past := time.Now().UTC().Add(-time.Minute).Format("2006-01-02T15:04:05Z")
|
|
u := &db.User{Banned: true, BanExpires: new(past)}
|
|
if auth.IsEffectivelyBanned(u) {
|
|
t.Error("IsEffectivelyBanned(Banned=true, ISO-8601 past expiry) = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestIsEffectivelyBanned_BannedFutureISO8601(t *testing.T) {
|
|
// ISO-8601 format for BanExpires in future — still banned.
|
|
future := time.Now().UTC().Add(time.Hour).Format("2006-01-02T15:04:05Z")
|
|
u := &db.User{Banned: true, BanExpires: new(future)}
|
|
if !auth.IsEffectivelyBanned(u) {
|
|
t.Error("IsEffectivelyBanned(Banned=true, ISO-8601 future expiry) = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestIsEffectivelyBanned_BannedUnparsableExpiry(t *testing.T) {
|
|
// Unparseable expiry string — fail-safe: treat as still banned.
|
|
u := &db.User{Banned: true, BanExpires: new("not-a-date")}
|
|
if !auth.IsEffectivelyBanned(u) {
|
|
t.Error("IsEffectivelyBanned(Banned=true, unparseable expiry) = false, want true (fail-safe)")
|
|
}
|
|
}
|
|
|
|
func TestIsEffectivelyBanned_NotBannedIgnoresExpiry(t *testing.T) {
|
|
// Banned=false even with a future expiry field — should be false.
|
|
future := time.Now().UTC().Add(time.Hour).Format("2006-01-02 15:04:05")
|
|
u := &db.User{Banned: false, BanExpires: new(future)}
|
|
if auth.IsEffectivelyBanned(u) {
|
|
t.Error("IsEffectivelyBanned(Banned=false, future expiry) = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestIsEffectivelyBanned_NilUser(t *testing.T) {
|
|
// A nil user pointer must not panic and must return false.
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Errorf("IsEffectivelyBanned(nil) panicked: %v", r)
|
|
}
|
|
}()
|
|
if auth.IsEffectivelyBanned(nil) {
|
|
t.Error("IsEffectivelyBanned(nil) = true, want false")
|
|
}
|
|
}
|
|
|
|
// ─── ValidateUsername ────────────────────────────────────────────────────────
|
|
|
|
func TestValidateUsername_ValidNames(t *testing.T) {
|
|
cases := []string{
|
|
"ab", // minimum length (2 runes)
|
|
"alice", // normal ASCII
|
|
"user_name", // with underscore
|
|
"日本語ユーザー", // CJK (multi-byte runes)
|
|
"abcdefghijklmnopqrstuvwxyz123456", // exactly 32 chars
|
|
}
|
|
for _, name := range cases {
|
|
if err := auth.ValidateUsername(name); err != nil {
|
|
t.Errorf("ValidateUsername(%q) = %v, want nil", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestValidateUsername_ReservedDeletedNamespace locks the anonymisation
|
|
// namespace shut. db.DeleteAccount renames a deleted account to
|
|
// "[deleted-<id>]" in a UNIQUE COLLATE NOCASE column, so while the name was
|
|
// registrable a member could take a chosen victim's and make every subsequent
|
|
// account-deletion attempt fail on the unique index.
|
|
func TestValidateUsername_ReservedDeletedNamespace(t *testing.T) {
|
|
reserved := []string{
|
|
"[deleted-42]", // the exact anonymised form
|
|
"[DELETED-42]", // UNIQUE is COLLATE NOCASE, so case must not help
|
|
"[Deleted-1]", // mixed case
|
|
"[deleted-42-a3f19c]", // the collision-fallback form
|
|
"[deleted-]", // the namespace, not just the numeric form
|
|
}
|
|
for _, name := range reserved {
|
|
if err := auth.ValidateUsername(name); err == nil {
|
|
t.Errorf("ValidateUsername(%q) = nil, want error for reserved namespace", name)
|
|
}
|
|
}
|
|
|
|
// Names that merely resemble it stay available.
|
|
allowed := []string{
|
|
"[deleted", // no closing bracket
|
|
"deleted-42]", // no opening bracket
|
|
"[not-deleted-1]", // different namespace
|
|
"[cool]", // ordinary bracketed name
|
|
}
|
|
for _, name := range allowed {
|
|
if err := auth.ValidateUsername(name); err != nil {
|
|
t.Errorf("ValidateUsername(%q) = %v, want nil", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateUsername_TooShort(t *testing.T) {
|
|
cases := []string{
|
|
"", // empty
|
|
"a", // single char
|
|
}
|
|
for _, name := range cases {
|
|
if err := auth.ValidateUsername(name); err == nil {
|
|
t.Errorf("ValidateUsername(%q) = nil, want error for too short", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateUsername_TooLong(t *testing.T) {
|
|
// 33 runes exceeds the 32-rune limit.
|
|
long := "abcdefghijklmnopqrstuvwxyz1234567"
|
|
if err := auth.ValidateUsername(long); err == nil {
|
|
t.Errorf("ValidateUsername(%q) = nil, want error for too long", long)
|
|
}
|
|
}
|
|
|
|
func TestValidateUsername_ControlCharactersRejected(t *testing.T) {
|
|
cases := []string{
|
|
"user\x00name", // null byte
|
|
"user\nname", // newline
|
|
"user\tname", // tab
|
|
"abc\x07def", // bell
|
|
}
|
|
for _, name := range cases {
|
|
if err := auth.ValidateUsername(name); err == nil {
|
|
t.Errorf("ValidateUsername(%q) = nil, want error for control char", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateUsername_InvisibleCharactersRejected(t *testing.T) {
|
|
// Zero-width joiner (U+200D) is in unicode.Cf category.
|
|
name := "user\u200Dname"
|
|
if err := auth.ValidateUsername(name); err == nil {
|
|
t.Errorf("ValidateUsername(%q) = nil, want error for invisible character", name)
|
|
}
|
|
|
|
// Zero-width space (U+200B).
|
|
name2 := "user\u200Bname"
|
|
if err := auth.ValidateUsername(name2); err == nil {
|
|
t.Errorf("ValidateUsername(%q) = nil, want error for zero-width space", name2)
|
|
}
|
|
}
|
|
|
|
func TestValidateUsername_WhitespaceTrimmed(t *testing.T) {
|
|
// Leading/trailing whitespace is trimmed, so " a " becomes "a" (1 rune = too short).
|
|
if err := auth.ValidateUsername(" a "); err == nil {
|
|
t.Error("ValidateUsername(\" a \") = nil, want error (trimmed to 1 rune)")
|
|
}
|
|
|
|
// After trimming, " ab " becomes "ab" (2 runes = valid).
|
|
if err := auth.ValidateUsername(" ab "); err != nil {
|
|
t.Errorf("ValidateUsername(\" ab \") = %v, want nil (trimmed to 2 runes)", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateUsername_UnicodeLength(t *testing.T) {
|
|
// Each emoji is 1 rune but multiple bytes. 2 emoji should be valid (min length).
|
|
twoEmoji := "😀😀"
|
|
if err := auth.ValidateUsername(twoEmoji); err != nil {
|
|
t.Errorf("ValidateUsername(%q) = %v, want nil for 2-rune emoji name", twoEmoji, err)
|
|
}
|
|
}
|