auth: switch user login to session token and decouple tenant access
This commit is contained in:
@@ -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[:])
|
||||
|
||||
Reference in New Issue
Block a user