auth: switch user login to session token and decouple tenant access

This commit is contained in:
2026-03-03 19:45:09 +08:00
parent 67bc6ecae6
commit 3b555df56c
6 changed files with 795 additions and 147 deletions

54
internal/server/authz.go Normal file
View File

@@ -0,0 +1,54 @@
package server
import (
"net/http"
"strconv"
)
type AccessContext struct {
Kind string
TenantID int64
UserID int64
Role string
Token string
}
func (s *Server) ResolveAccess(r *http.Request, masterToken uint64) (*AccessContext, bool) {
tok := BearerToken(r)
if tok == "" {
return nil, false
}
if tok == strconv.FormatUint(masterToken, 10) {
return &AccessContext{Kind: "master", Role: "admin", Token: tok}, true
}
return s.ResolveTenantAccessToken(tok)
}
func (s *Server) ResolveTenantAccessToken(tok string) (*AccessContext, bool) {
if tok == "" || s.store == nil {
return nil, false
}
if ss, err := s.store.VerifySessionToken(tok); err == nil && ss != nil {
return &AccessContext{
Kind: "session",
TenantID: ss.TenantID,
UserID: ss.UserID,
Role: ss.Role,
Token: tok,
}, true
}
if ten, err := s.store.VerifyAPIKey(tok); err == nil && ten != nil {
return &AccessContext{
Kind: "apikey",
TenantID: ten.ID,
Role: "apikey",
Token: tok,
}, true
}
return nil, false
}

View File

@@ -34,12 +34,47 @@ func writeJSON(w http.ResponseWriter, status int, body string) {
}
func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
tenants, err := s.store.ListTenants()
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list tenants failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Tenants []store.Tenant `json:"tenants"`
}{0, "ok", tenants}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
return
}
// update tenant status via /api/v1/admin/tenants/{id}?status=0|1
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/admin/tenants/") {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) >= 4 {
var id int64
_, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &id)
st := r.URL.Query().Get("status")
if id > 0 && st != "" {
status := 0
if st == "1" {
status = 1
}
_ = s.store.UpdateTenantStatus(id, status)
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
}
}
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
var req struct {
Name string `json:"name"`
Name string `json:"name"`
AdminPassword string `json:"admin_password"`
OperatorPassword string `json:"operator_password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
@@ -49,23 +84,39 @@ func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
ten, err := s.store.CreateTenant(req.Name)
var ten *store.Tenant
var admin *store.User
var op *store.User
var err error
if req.AdminPassword != "" && req.OperatorPassword != "" {
ten, admin, op, err = s.store.CreateTenantWithUsers(req.Name, req.AdminPassword, req.OperatorPassword)
} else {
ten, err = s.store.CreateTenant(req.Name)
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create tenant failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Tenant int64 `json:"tenant_id"`
Subnet string `json:"subnet"`
}{0, "ok", ten.ID, ten.Subnet}
Error int `json:"error"`
Message string `json:"message"`
Tenant int64 `json:"tenant_id"`
Subnet string `json:"subnet"`
AdminUser string `json:"admin_user"`
OperatorUser string `json:"operator_user"`
}{0, "ok", ten.ID, ten.Subnet, "", ""}
if admin != nil {
resp.AdminUser = admin.Email
}
if op != nil {
resp.OperatorUser = op.Email
}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
}
func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
if r.Method != http.MethodPost && r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
@@ -87,6 +138,37 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
if r.Method == http.MethodGet {
keys, err := s.store.ListAPIKeys(tenantID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list keys failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Keys []store.APIKey `json:"keys"`
}{0, "ok", keys}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
return
}
// update key status via /api/v1/admin/tenants/{id}/keys/{keyId}?status=0|1
if strings.Contains(r.URL.Path, "/keys/") {
parts2 := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
var keyID int64
_, _ = fmt.Sscanf(parts2[len(parts2)-1], "%d", &keyID)
st := r.URL.Query().Get("status")
if keyID > 0 && st != "" {
status := 0
if st == "1" {
status = 1
}
_ = s.store.UpdateAPIKeyStatus(keyID, status)
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
}
var req struct {
Scope string `json:"scope"`
TTL int64 `json:"ttl"` // seconds
@@ -112,21 +194,41 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request)
}
func (s *Server) HandleTenantEnroll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
if r.Method != http.MethodPost && r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
// tenant auth by API key
// tenant auth by session/apikey
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
tok := BearerToken(r)
ten, err := s.store.VerifyAPIKey(tok)
if err != nil || ten == nil {
ac, ok := s.ResolveTenantAccessToken(tok)
if !ok || ac.TenantID <= 0 {
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`)
return
}
ten, err := s.store.GetTenantByID(ac.TenantID)
if err != nil || ten == nil || ten.Status != 1 {
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`)
return
}
if r.Method == http.MethodGet {
tokens, err := s.store.ListEnrollTokens(ten.ID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list enroll failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Enrolls []store.EnrollToken `json:"enrolls"`
}{0, "ok", tokens}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
return
}
code, err := s.store.CreateEnrollToken(ten.ID, 10*time.Minute, 5)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create enroll failed"}`)
@@ -147,6 +249,24 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
// revoke support: /api/v1/enroll/consume/{id}?status=0
if strings.Contains(r.URL.Path, "/enroll/consume/") {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) >= 4 {
var id int64
_, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &id)
st := r.URL.Query().Get("status")
if id > 0 && st != "" {
status := 0
if st == "1" {
status = 1
}
_ = s.store.UpdateEnrollStatus(id, status)
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
}
}
var req struct {
Code string `json:"code"`
NodeName string `json:"node"`
@@ -171,12 +291,13 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
NodeID int64 `json:"node_id"`
Secret string `json:"node_secret"`
Tenant int64 `json:"tenant_id"`
}{0, "ok", cred.NodeID, cred.Secret, cred.TenantID}
Error int `json:"error"`
Message string `json:"message"`
NodeID int64 `json:"node_id"`
Secret string `json:"node_secret"`
Tenant int64 `json:"tenant_id"`
CreatedAt int64 `json:"created_at"`
}{0, "ok", cred.NodeID, cred.Secret, cred.TenantID, cred.CreatedAt}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
}

View File

@@ -10,6 +10,7 @@ import (
"net"
"time"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
@@ -18,27 +19,43 @@ type Store struct {
}
type Tenant struct {
ID int64
Name string
Status int
Subnet string
ID int64
Name string
Status int
Subnet string
CreatedAt int64
}
type User struct {
ID int64
TenantID int64
Role string
Email string
PasswordHash string
Status int
CreatedAt int64
}
type APIKey struct {
ID int64
TenantID int64
Hash string
Scope string
Expires *time.Time
Status int
ID int64
TenantID int64
Hash string
Scope string
ExpiresAt *int64
Status int
CreatedAt int64
Plain string
}
type NodeCredential struct {
NodeID int64
NodeName string
Secret string
VirtualIP string
TenantID int64
NodeID int64
NodeName string
Secret string
VirtualIP string
TenantID int64
Status int
CreatedAt int64
LastSeen *int64
}
type EnrollToken struct {
@@ -50,6 +67,18 @@ type EnrollToken struct {
MaxAttempt int
Attempts int
Status int
CreatedAt int64
}
type SessionToken struct {
ID int64
UserID int64
TenantID int64
Role string
TokenHash string
ExpiresAt int64
Status int
CreatedAt int64
}
func Open(dbPath string) (*Store, error) {
@@ -111,6 +140,7 @@ func (s *Store) migrate() error {
virtual_ip TEXT,
status INTEGER NOT NULL DEFAULT 1,
last_seen INTEGER,
created_at INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
);`,
`CREATE TABLE IF NOT EXISTS enroll_tokens (
@@ -154,6 +184,18 @@ func (s *Store) migrate() error {
tenant_id INTEGER,
updated_at INTEGER NOT NULL
);`,
`CREATE TABLE IF NOT EXISTS session_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
tenant_id INTEGER NOT NULL,
role TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
);`,
}
for _, stmt := range stmts {
if _, err := s.DB.Exec(stmt); err != nil {
@@ -215,25 +257,45 @@ func (s *Store) CreateTenant(name string) (*Tenant, error) {
}
id, _ := res.LastInsertId()
_, _ = s.DB.Exec(`UPDATE subnet_pool SET status=1, tenant_id=?, updated_at=? WHERE subnet=?`, id, now, sn)
return &Tenant{ID: id, Name: name, Status: 1, Subnet: sn}, nil
return &Tenant{ID: id, Name: name, Status: 1, Subnet: sn, CreatedAt: now}, nil
}
func (s *Store) CreateTenantWithUsers(name, adminPassword, operatorPassword string) (*Tenant, *User, *User, error) {
if adminPassword == "" || operatorPassword == "" {
return nil, nil, nil, errors.New("password required")
}
ten, err := s.CreateTenant(name)
if err != nil {
return nil, nil, nil, err
}
admin, err := s.CreateUser(ten.ID, "admin", "admin@local", adminPassword, 1)
if err != nil {
return nil, nil, nil, err
}
op, err := s.CreateUser(ten.ID, "operator", "operator@local", operatorPassword, 1)
if err != nil {
return nil, nil, nil, err
}
return ten, admin, op, nil
}
func (s *Store) CreateNodeCredential(tenantID int64, nodeName string) (*NodeCredential, error) {
secret := randToken()
h := hashTokenString(secret)
res, err := s.DB.Exec(`INSERT INTO nodes(tenant_id,node_name,node_secret_hash,status) VALUES(?,?,?,1)`, tenantID, nodeName, h)
now := time.Now().Unix()
res, err := s.DB.Exec(`INSERT INTO nodes(tenant_id,node_name,node_secret_hash,status,last_seen,created_at) VALUES(?,?,?,1,?,?)`, tenantID, nodeName, h, now, now)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return &NodeCredential{NodeID: id, NodeName: nodeName, Secret: secret, TenantID: tenantID}, nil
return &NodeCredential{NodeID: id, NodeName: nodeName, Secret: secret, TenantID: tenantID, Status: 1, CreatedAt: now, LastSeen: &now}, nil
}
func (s *Store) VerifyNodeSecret(nodeName, secret string) (*Tenant, error) {
h := hashTokenString(secret)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM nodes n JOIN tenants t ON n.tenant_id=t.id WHERE n.node_name=? AND n.node_secret_hash=? AND n.status=1`, nodeName, h)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet,t.created_at FROM nodes n JOIN tenants t ON n.tenant_id=t.id WHERE n.node_name=? AND n.node_secret_hash=? AND n.status=1 AND t.status=1`, nodeName, h)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet); err != nil {
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil {
return nil, err
}
return &t, nil
@@ -241,9 +303,18 @@ func (s *Store) VerifyNodeSecret(nodeName, secret string) (*Tenant, error) {
func (s *Store) GetTenantByToken(token uint64) (*Tenant, error) {
h := hashToken(token)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1`, h)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet,t.created_at FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1 AND t.status=1`, h)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet); err != nil {
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) GetTenantByID(id int64) (*Tenant, error) {
row := s.DB.QueryRow(`SELECT id,name,status,subnet,created_at FROM tenants WHERE id=?`, id)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil {
return nil, err
}
return &t, nil
@@ -274,10 +345,10 @@ func (s *Store) CreateEnrollToken(tenantID int64, ttl time.Duration, maxAttempt
func (s *Store) ConsumeEnrollToken(code string) (*EnrollToken, error) {
h := hashTokenString(code)
now := time.Now().Unix()
row := s.DB.QueryRow(`SELECT id,tenant_id,expires_at,used_at,max_attempt,attempts,status FROM enroll_tokens WHERE token_hash=?`, h)
row := s.DB.QueryRow(`SELECT id,tenant_id,expires_at,used_at,max_attempt,attempts,status,created_at FROM enroll_tokens WHERE token_hash=?`, h)
var et EnrollToken
var used sql.NullInt64
if err := row.Scan(&et.ID, &et.TenantID, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status); err != nil {
if err := row.Scan(&et.ID, &et.TenantID, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status, &et.CreatedAt); err != nil {
return nil, err
}
if used.Valid {
@@ -306,6 +377,97 @@ func (s *Store) IncEnrollAttempt(code string) {
_, _ = s.DB.Exec(`UPDATE enroll_tokens SET attempts=attempts+1 WHERE token_hash=?`, h)
}
// ListTenants returns all tenants (admin)
func (s *Store) ListTenants() ([]Tenant, error) {
rows, err := s.DB.Query(`SELECT id,name,status,subnet,created_at FROM tenants ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Tenant
for rows.Next() {
var t Tenant
if err := rows.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err == nil {
out = append(out, t)
}
}
return out, nil
}
func (s *Store) UpdateTenantStatus(id int64, status int) error {
_, err := s.DB.Exec(`UPDATE tenants SET status=? WHERE id=?`, status, id)
return err
}
func (s *Store) DeleteTenant(id int64) error {
_, err := s.DB.Exec(`DELETE FROM tenants WHERE id=?`, id)
return err
}
// ListAPIKeys returns api keys of a tenant (admin)
func (s *Store) ListAPIKeys(tenantID int64) ([]APIKey, error) {
rows, err := s.DB.Query(`SELECT id,tenant_id,key_hash,scope,expires_at,status,created_at FROM api_keys WHERE tenant_id=? ORDER BY id DESC`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []APIKey
for rows.Next() {
var k APIKey
var exp sql.NullInt64
if err := rows.Scan(&k.ID, &k.TenantID, &k.Hash, &k.Scope, &exp, &k.Status, &k.CreatedAt); err == nil {
if exp.Valid {
v := exp.Int64
k.ExpiresAt = &v
}
out = append(out, k)
}
}
return out, nil
}
func (s *Store) UpdateAPIKeyStatus(id int64, status int) error {
_, err := s.DB.Exec(`UPDATE api_keys SET status=? WHERE id=?`, status, id)
return err
}
func (s *Store) DeleteAPIKey(id int64) error {
_, err := s.DB.Exec(`DELETE FROM api_keys WHERE id=?`, id)
return err
}
// ListEnrollTokens returns enroll tokens for a tenant (admin)
func (s *Store) ListEnrollTokens(tenantID int64) ([]EnrollToken, error) {
rows, err := s.DB.Query(`SELECT id,tenant_id,token_hash,expires_at,used_at,max_attempt,attempts,status,created_at FROM enroll_tokens WHERE tenant_id=? ORDER BY id DESC`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []EnrollToken
for rows.Next() {
var et EnrollToken
var used sql.NullInt64
if err := rows.Scan(&et.ID, &et.TenantID, &et.Hash, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status, &et.CreatedAt); err == nil {
if used.Valid {
v := used.Int64
et.UsedAt = &v
}
out = append(out, et)
}
}
return out, nil
}
func (s *Store) UpdateEnrollStatus(id int64, status int) error {
_, err := s.DB.Exec(`UPDATE enroll_tokens SET status=? WHERE id=?`, status, id)
return err
}
func (s *Store) DeleteEnrollToken(id int64) error {
_, err := s.DB.Exec(`DELETE FROM enroll_tokens WHERE id=?`, id)
return err
}
func hashToken(token uint64) string {
b := make([]byte, 8)
for i := uint(0); i < 8; i++ {
@@ -320,14 +482,149 @@ func hashTokenString(token string) string {
func (s *Store) VerifyAPIKey(token string) (*Tenant, error) {
h := hashTokenString(token)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1`, h)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet,t.created_at FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1 AND t.status=1`, h)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet); err != nil {
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) GetUserByTenant(tenantID int64) (*User, error) {
row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? ORDER BY id LIMIT 1`, tenantID)
var u User
if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) GetUserByToken(token string) (*User, error) {
h := hashTokenString(token)
row := s.DB.QueryRow(`SELECT u.id,u.tenant_id,u.role,u.email,u.password_hash,u.status,u.created_at FROM api_keys k JOIN users u ON k.tenant_id=u.tenant_id WHERE k.key_hash=? AND k.status=1`, h)
var u User
if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) GetUserByEmail(tenantID int64, email string) (*User, error) {
row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? AND email=? ORDER BY id LIMIT 1`, tenantID, email)
var u User
if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) CreateUser(tenantID int64, role, email, password string, status int) (*User, error) {
now := time.Now().Unix()
var hash string
if password != "" {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
hash = string(b)
}
res, err := s.DB.Exec(`INSERT INTO users(tenant_id,role,email,password_hash,status,created_at) VALUES(?,?,?,?,?,?)`, tenantID, role, email, hash, status, now)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return &User{ID: id, TenantID: tenantID, Role: role, Email: email, PasswordHash: hash, Status: status, CreatedAt: now}, nil
}
func (s *Store) UserEmailExists(tenantID int64, email string) (bool, error) {
row := s.DB.QueryRow(`SELECT COUNT(1) FROM users WHERE tenant_id=? AND email=?`, tenantID, email)
var c int
if err := row.Scan(&c); err != nil {
return false, err
}
return c > 0, nil
}
func (s *Store) VerifyUserPassword(tenantID int64, email, password string) (*User, error) {
u, err := s.GetUserByEmail(tenantID, email)
if err != nil {
// compatibility: allow login by role name (admin/operator)
row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? AND role=? ORDER BY id LIMIT 1`, tenantID, email)
var byRole User
if scanErr := row.Scan(&byRole.ID, &byRole.TenantID, &byRole.Role, &byRole.Email, &byRole.PasswordHash, &byRole.Status, &byRole.CreatedAt); scanErr != nil {
return nil, err
}
u = &byRole
}
if u.PasswordHash == "" {
return nil, errors.New("password not set")
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("invalid password")
}
return u, nil
}
func (s *Store) CreateSessionToken(userID, tenantID int64, role string, ttl time.Duration) (string, int64, error) {
tok := randToken()
h := hashTokenString(tok)
now := time.Now().Unix()
exp := time.Now().Add(ttl).Unix()
_, err := s.DB.Exec(`INSERT INTO session_tokens(user_id,tenant_id,role,token_hash,expires_at,status,created_at) VALUES(?,?,?,?,?,1,?)`, userID, tenantID, role, h, exp, now)
if err != nil {
return "", 0, err
}
return tok, exp, nil
}
func (s *Store) VerifySessionToken(token string) (*SessionToken, error) {
h := hashTokenString(token)
now := time.Now().Unix()
row := s.DB.QueryRow(`SELECT id,user_id,tenant_id,role,token_hash,expires_at,status,created_at FROM session_tokens WHERE token_hash=? AND status=1 AND expires_at>?`, h, now)
var st SessionToken
if err := row.Scan(&st.ID, &st.UserID, &st.TenantID, &st.Role, &st.TokenHash, &st.ExpiresAt, &st.Status, &st.CreatedAt); err != nil {
return nil, err
}
return &st, nil
}
func (s *Store) RevokeSessionToken(token string) error {
h := hashTokenString(token)
_, err := s.DB.Exec(`UPDATE session_tokens SET status=0 WHERE token_hash=?`, h)
return err
}
func (s *Store) ListUsers(tenantID int64) ([]User, error) {
rows, err := s.DB.Query(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? ORDER BY id`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []User{}
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
return nil, err
}
out = append(out, u)
}
return out, nil
}
func (s *Store) UpdateUserStatus(id int64, status int) error {
_, err := s.DB.Exec(`UPDATE users SET status=? WHERE id=?`, status, id)
return err
}
func (s *Store) UpdateUserPassword(id int64, password string) error {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = s.DB.Exec(`UPDATE users SET password_hash=? WHERE id=?`, string(b), id)
return err
}
func hashTokenBytes(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])