Skip to content

Commit e84e766

Browse files
committed
refactor(auth): consolidate user+ACL into single UserACLList; drop passwords; TOTP-only login
Schema: - auth/users.go + auth/acl.go merged → auth/user_acl.go (single source of truth) - User struct: Display + ACL map (no PasswordHash field — passwords removed) - UserACLList map[username]User exposed at package scope - Helpers AllowedProjects / CanSeeProject / TagAllowed read from UserACLList[user].ACL Auth flow (no traditional password exists at all): - /login form: username + 6-digit TOTP only (no password field) - First login: server sees UserACLList has the username + TOTP not bound → sets bind cookie + 302 to /bind-totp; the TOTP field on this submission is ignored - /bind-totp: scan QR + enter first 6-digit code → blob written - Subsequent logins: username + TOTP code → validate → session cookie - TOTP IS the credential CLI: - hash subcommand removed (no passwords to hash) - totp-reset → reset_totp (snake_case to match user's project conventions) Dependency cleanup: - drop golang.org/x/crypto/bcrypt - drop golang.org/x/term Security envelope: LAN-only deployment. The /login surface trusts that anyone on the LAN may attempt enrolment for any listed username (because there's no password barrier before the bind flow). If too permissive, restrict at firewall or extend CLI for admin-pre-bind. Documented threat model in user_acl.go header.
1 parent 5f14ce1 commit e84e766

9 files changed

Lines changed: 147 additions & 168 deletions

File tree

.gitignore

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,23 @@
1010
logs/
1111

1212
# build artifacts
13-
/api_show
14-
/bin/
15-
/build/
13+
api_show
14+
bin/
15+
build/
1616

1717
# runtime data — uploaded specs live here at runtime, not in git
18-
/data/*
19-
!/data/.gitkeep
18+
data/*
19+
!data/.gitkeep
2020

2121
# Production config is auto-created by SyncConfigFile at first start
2222
# (/api_show/conf/api_show_config.toml). Dev config_local.toml is committed
2323
# with placeholder values (mirrors ame); devs must NOT commit secret edits.
24-
/conf/
24+
conf/
2525

2626
# .auto_coding skill workspace + private CLAUDE.md (per repo convention)
2727
.auto_coding/
2828
.CLAUDE.md
2929
CLAUDE.md
3030
.claude/
3131

32-
api_show
32+

auth/acl.go

Lines changed: 0 additions & 67 deletions
This file was deleted.

auth/cli.go

Lines changed: 10 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,64 +4,38 @@ import (
44
"errors"
55
"fmt"
66
"os"
7-
8-
"golang.org/x/crypto/bcrypt"
9-
"golang.org/x/term"
107
)
118

129
// RunCLI dispatches admin sub-commands when api_show is invoked with one.
1310
// Returns (handled, exitCode). Caller should exit immediately if handled.
1411
//
1512
// Sub-commands:
1613
//
17-
// ./api_show hash — read a password from stdin, print bcrypt
18-
// ./api_show totp-reset <user> — delete that user's TOTP blob
14+
// ./api_show reset_totp <username> — delete that user's TOTP blob;
15+
// forces them to re-bind on next login.
1916
//
20-
// `hash` deliberately does NOT take the password as an argv argument so it
21-
// never lands in shell history or `ps` output.
17+
// No password subcommand exists — there ARE no passwords; TOTP is the
18+
// credential. To rotate a user's TOTP, run reset_totp and the user's
19+
// next /login goes through the bind flow.
2220
func RunCLI(args []string, dataDir string, secret []byte) (handled bool, exitCode int) {
2321
if len(args) == 0 {
2422
return false, 0
2523
}
2624
switch args[0] {
27-
case "hash":
28-
return true, cmdHash()
29-
case "totp-reset":
25+
case "reset_totp":
3026
if len(args) < 2 {
31-
fmt.Fprintln(os.Stderr, "usage: api_show totp-reset <username>")
27+
fmt.Fprintln(os.Stderr, "usage: api_show reset_totp <username>")
3228
return true, 2
3329
}
34-
return true, cmdTotpReset(args[1], dataDir, secret)
30+
return true, cmdResetTotp(args[1], dataDir, secret)
3531
default:
3632
return false, 0
3733
}
3834
}
3935

40-
func cmdHash() int {
41-
fmt.Fprint(os.Stderr, "password (input hidden): ")
42-
pw, err := term.ReadPassword(int(os.Stdin.Fd()))
43-
fmt.Fprintln(os.Stderr) // newline after hidden input
44-
if err != nil {
45-
fmt.Fprintln(os.Stderr, "read password:", err)
46-
return 1
47-
}
48-
if len(pw) == 0 {
49-
fmt.Fprintln(os.Stderr, "empty password rejected")
50-
return 1
51-
}
52-
hash, err := bcrypt.GenerateFromPassword(pw, 10)
53-
if err != nil {
54-
fmt.Fprintln(os.Stderr, "bcrypt:", err)
55-
return 1
56-
}
57-
fmt.Println(string(hash))
58-
fmt.Fprintln(os.Stderr, "→ paste into auth/users.go Users map and ship a new release.")
59-
return 0
60-
}
61-
62-
func cmdTotpReset(user, dataDir string, secret []byte) int {
36+
func cmdResetTotp(user, dataDir string, secret []byte) int {
6337
if !UserExists(user) {
64-
fmt.Fprintf(os.Stderr, "warn: %q is not in auth.Usersresetting blob anyway in case of stale entry\n", user)
38+
fmt.Fprintf(os.Stderr, "warn: %q is not in auth.UserACLListclearing blob anyway in case of stale entry\n", user)
6539
}
6640
store := NewTOTPStore(dataDir, secret)
6741
path := store.Path(user)

auth/handlers.go

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"sync"
99

1010
"github.com/pquerna/otp"
11-
"golang.org/x/crypto/bcrypt"
1211
)
1312

1413
// HandlerDeps bundles the shared state every handler needs.
@@ -40,26 +39,22 @@ func loginSubmit(w http.ResponseWriter, r *http.Request, d HandlerDeps) {
4039
return
4140
}
4241
user := strings.TrimSpace(r.FormValue("username"))
43-
pass := r.FormValue("password")
4442
code := strings.TrimSpace(r.FormValue("totp"))
4543
next := safeNext(r.FormValue("next"))
46-
if user == "" || pass == "" {
47-
renderLogin(w, "username + password required", next)
44+
if user == "" {
45+
renderLogin(w, "username required", next)
4846
return
4947
}
50-
hash, ok := Users[user]
51-
if !ok {
52-
renderLogin(w, "invalid credentials", next)
53-
return
54-
}
55-
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)); err != nil {
48+
if !UserExists(user) {
49+
// Don't leak whether the username is valid before TOTP — same
50+
// generic error either way.
5651
renderLogin(w, "invalid credentials", next)
5752
return
5853
}
5954

60-
// Password OK — branch on TOTP binding state
55+
// First login: no bound TOTP yet → set bind cookie + 302 to QR page.
56+
// The `code` field is ignored on this path.
6157
if !d.TOTP.IsBound(user) {
62-
// First-time user → temp bind cookie + redirect to QR page
6358
if err := IssueBindCookie(w, d.Secret, user); err != nil {
6459
http.Error(w, "set bind cookie: "+err.Error(), http.StatusInternalServerError)
6560
return
@@ -68,13 +63,14 @@ func loginSubmit(w http.ResponseWriter, r *http.Request, d HandlerDeps) {
6863
return
6964
}
7065

66+
// Subsequent login: TOTP IS the credential. No password.
7167
if code == "" {
7268
renderLogin(w, "TOTP code required", next)
7369
return
7470
}
7571
verified, err := d.TOTP.Verify(user, code)
7672
if err != nil || !verified {
77-
renderLogin(w, "TOTP code invalid", next)
73+
renderLogin(w, "invalid credentials", next)
7874
return
7975
}
8076
if err := IssueSessionCookie(w, d.Secret, user); err != nil {
@@ -234,13 +230,11 @@ var loginTpl = template.Must(template.New("login").Parse(`<!doctype html>
234230
<input type="hidden" name="next" value="{{.Next}}">
235231
<label>Username</label>
236232
<input name="username" autocomplete="username" autofocus required>
237-
<label>Password</label>
238-
<input name="password" type="password" autocomplete="current-password" required>
239233
<label>TOTP code <span style="opacity:.6">(leave blank on first login)</span></label>
240234
<input name="totp" inputmode="numeric" pattern="[0-9]{6}" autocomplete="one-time-code" placeholder="6-digit">
241235
<button type="submit">Sign in</button>
242236
<div class="err">{{.Err}}</div>
243-
<div class="hint">First-time users skip the TOTP field — you'll be redirected to bind your authenticator app.</div>
237+
<div class="hint">No password — TOTP is the credential. First-time users leave the TOTP field empty and will be redirected to bind an authenticator app (Google Authenticator / Authy / 1Password).</div>
244238
</form>
245239
</div>
246240
</body></html>`))

auth/spec_filter.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ import (
1919
// Spec versions supported: OpenAPI 3.x (also tolerates Swagger 2.0 layout —
2020
// "paths" + "tags" are identical there).
2121
func FilterSpec(raw []byte, user, project string) ([]byte, error) {
22-
allowed, ok := ACL[user][project]
22+
u, ok := UserACLList[user]
23+
if !ok {
24+
return nil, fmt.Errorf("auth: user %q not registered", user)
25+
}
26+
allowed, ok := u.ACL[project]
2327
if !ok || len(allowed) == 0 {
2428
return nil, fmt.Errorf("auth: user %q has no visibility into project %q", user, project)
2529
}

auth/user_acl.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Package auth implements api_show's authentication and per-project ACL.
2+
//
3+
// No traditional password exists. TOTP is the credential — first login
4+
// binds a fresh TOTP secret to the username; every subsequent login is
5+
// "username + 6-digit TOTP". Admin wipes a user's TOTP via:
6+
//
7+
// ./api_show reset_totp <username>
8+
//
9+
// Security envelope: LAN-only deployment. The /login surface trusts that
10+
// anyone already on the LAN may attempt enrolment for any listed username.
11+
// If that's too permissive, gate the listening port at the firewall or
12+
// extend the CLI to admin-pre-bind TOTP secrets out of band.
13+
package auth
14+
15+
// User is the full per-account record. No password — TOTP is the credential.
16+
type User struct {
17+
// Display is an optional friendly name shown on the bind QR / portal UI.
18+
// Empty string falls back to the map key (username).
19+
Display string
20+
21+
// ACL maps project_id → allowed-tag-prefix list.
22+
//
23+
// Tag matching rules:
24+
// - "*" ← all tags allowed (full project view)
25+
// - "auth" ← matches operations whose tag starts with "auth"
26+
// (e.g. "auth", "auth.login", "auth.session")
27+
// - empty list ← user cannot see this project
28+
// - project not listed for user ← also denied
29+
ACL map[string][]string
30+
}
31+
32+
// UserACLList — hardcoded account table. Edit + ship a new release to add
33+
// users or change permissions.
34+
//
35+
// Demo entries (replace before LAN deploy):
36+
//
37+
// ohyeah → full cg, ame[auth+orgs]
38+
// hl → cg[chat] only
39+
var UserACLList = map[string]User{
40+
"ohyeah": {
41+
Display: "OhYeah",
42+
ACL: map[string][]string{
43+
"cg": {"*"},
44+
"ame": {"auth", "orgs"},
45+
},
46+
},
47+
"hl": {
48+
Display: "hl",
49+
ACL: map[string][]string{
50+
"cg": {"chat"},
51+
},
52+
},
53+
}
54+
55+
// UserExists reports whether a username is registered.
56+
func UserExists(username string) bool {
57+
_, ok := UserACLList[username]
58+
return ok
59+
}
60+
61+
// LookupUser returns the User record + ok flag for username.
62+
func LookupUser(username string) (User, bool) {
63+
u, ok := UserACLList[username]
64+
return u, ok
65+
}
66+
67+
// AllowedProjects returns the project IDs a user can see at all.
68+
// Order is undefined; caller should not rely on it.
69+
func AllowedProjects(user string) []string {
70+
u, ok := UserACLList[user]
71+
if !ok {
72+
return nil
73+
}
74+
out := make([]string, 0, len(u.ACL))
75+
for p, tags := range u.ACL {
76+
if len(tags) == 0 {
77+
continue
78+
}
79+
out = append(out, p)
80+
}
81+
return out
82+
}
83+
84+
// CanSeeProject reports whether user has any visibility into project.
85+
func CanSeeProject(user, project string) bool {
86+
u, ok := UserACLList[user]
87+
if !ok {
88+
return false
89+
}
90+
tags, ok := u.ACL[project]
91+
return ok && len(tags) > 0
92+
}
93+
94+
// TagAllowed reports whether the given operation tag is permitted for
95+
// (user, project). Empty tag means "untagged operation" → matches "*" only.
96+
func TagAllowed(user, project, tag string) bool {
97+
u, ok := UserACLList[user]
98+
if !ok {
99+
return false
100+
}
101+
allowed, ok := u.ACL[project]
102+
if !ok {
103+
return false
104+
}
105+
for _, prefix := range allowed {
106+
if prefix == "*" {
107+
return true
108+
}
109+
if tag != "" && len(tag) >= len(prefix) && tag[:len(prefix)] == prefix {
110+
return true
111+
}
112+
}
113+
return false
114+
}

0 commit comments

Comments
 (0)