Files
configcenter/internal/auth/auth.go

225 lines
6.8 KiB
Go

package auth
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/longpeng/configcenter/internal/domain"
"github.com/longpeng/configcenter/internal/store"
)
var (
ErrUnauthorized = errors.New("authentication required")
ErrForbidden = errors.New("permission denied")
)
const (
RoleViewer = "viewer"
RoleAppOwner = "app-owner"
RoleAdmin = "admin"
)
type Manager struct {
enabled bool
secret []byte
ttl time.Duration
store store.Store
now func() time.Time
}
type tokenClaims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
UserID int64 `json:"uid"`
DisplayName string `json:"name"`
Admin bool `json:"admin"`
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp"`
}
type principalKey struct{}
func New(repository store.Store, enabled bool, secret string, ttl time.Duration) (*Manager, error) {
if enabled && len(secret) < 32 {
return nil, errors.New("JWT_SECRET must contain at least 32 characters when authentication is enabled")
}
if ttl <= 0 {
return nil, errors.New("JWT_TTL must be positive")
}
return &Manager{enabled: enabled, secret: []byte(secret), ttl: ttl, store: repository, now: time.Now}, nil
}
func (m *Manager) Enabled() bool { return m.enabled }
func (m *Manager) TTLSeconds() int64 { return int64(m.ttl.Seconds()) }
func (m *Manager) Bootstrap(ctx context.Context, username, password, displayName string) error {
if !m.enabled {
return nil
}
if strings.TrimSpace(username) == "" || len(password) < 12 {
return errors.New("bootstrap admin username is required and password must contain at least 12 characters")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash bootstrap password: %w", err)
}
return m.store.EnsureBootstrapAdmin(ctx, normalizeUsername(username), string(hash), strings.TrimSpace(displayName))
}
func (m *Manager) Login(ctx context.Context, username, password string) (string, domain.Principal, error) {
if !m.enabled {
principal := domain.Principal{Username: "admin", DisplayName: "Development Admin", IsAdmin: true}
token, err := m.issue(principal)
return token, principal, err
}
credential, err := m.store.FindUserByUsername(ctx, normalizeUsername(username))
if err != nil || credential.Disabled || bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(password)) != nil {
return "", domain.Principal{}, ErrUnauthorized
}
principal := domain.Principal{UserID: credential.ID, Username: credential.Username, DisplayName: credential.DisplayName, IsAdmin: credential.IsAdmin}
token, err := m.issue(principal)
return token, principal, err
}
func (m *Manager) CreateUser(ctx context.Context, input domain.User, password string) (domain.User, error) {
if len(password) < 12 {
return domain.User{}, errors.New("password must contain at least 12 characters")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return domain.User{}, err
}
input.Username = normalizeUsername(input.Username)
return m.store.CreateUser(ctx, input, string(hash))
}
func (m *Manager) AuthenticateRequest(r *http.Request) (domain.Principal, error) {
if !m.enabled {
return domain.Principal{Username: "admin", DisplayName: "Development Admin", IsAdmin: true}, nil
}
header := strings.TrimSpace(r.Header.Get("Authorization"))
if !strings.HasPrefix(header, "Bearer ") {
return domain.Principal{}, ErrUnauthorized
}
return m.parse(strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")))
}
func WithPrincipal(ctx context.Context, principal domain.Principal) context.Context {
return context.WithValue(ctx, principalKey{}, principal)
}
func Principal(ctx context.Context) (domain.Principal, bool) {
principal, ok := ctx.Value(principalKey{}).(domain.Principal)
return principal, ok
}
func (m *Manager) RequireAdmin(ctx context.Context) error {
principal, ok := Principal(ctx)
if !ok {
return ErrUnauthorized
}
if !principal.IsAdmin {
return ErrForbidden
}
return nil
}
func (m *Manager) RequireAppRole(ctx context.Context, appID int64, minimum string) error {
principal, ok := Principal(ctx)
if !ok {
return ErrUnauthorized
}
if principal.IsAdmin {
return nil
}
role, err := m.store.GetUserAppRole(ctx, principal.UserID, appID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return ErrForbidden
}
return err
}
if roleRank(role) < roleRank(minimum) {
return ErrForbidden
}
return nil
}
func (m *Manager) CanAccessApp(ctx context.Context, appID int64, minimum string) bool {
return m.RequireAppRole(ctx, appID, minimum) == nil
}
func (m *Manager) issue(principal domain.Principal) (string, error) {
now := m.now().UTC()
claims := tokenClaims{Issuer: "configcenter", Subject: principal.Username, UserID: principal.UserID, DisplayName: principal.DisplayName, Admin: principal.IsAdmin, IssuedAt: now.Unix(), ExpiresAt: now.Add(m.ttl).Unix()}
header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
unsigned := encode(header) + "." + encode(payload)
signature := m.sign(unsigned)
return unsigned + "." + encode(signature), nil
}
func (m *Manager) parse(token string) (domain.Principal, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return domain.Principal{}, ErrUnauthorized
}
unsigned := parts[0] + "." + parts[1]
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || !hmac.Equal(signature, m.sign(unsigned)) {
return domain.Principal{}, ErrUnauthorized
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return domain.Principal{}, ErrUnauthorized
}
var claims tokenClaims
if err := json.Unmarshal(payload, &claims); err != nil || claims.Issuer != "configcenter" || claims.Subject == "" {
return domain.Principal{}, ErrUnauthorized
}
now := m.now().Unix()
if claims.ExpiresAt <= now || claims.IssuedAt > now+60 {
return domain.Principal{}, ErrUnauthorized
}
return domain.Principal{UserID: claims.UserID, Username: claims.Subject, DisplayName: claims.DisplayName, IsAdmin: claims.Admin}, nil
}
func (m *Manager) sign(input string) []byte {
mac := hmac.New(sha256.New, m.secret)
_, _ = mac.Write([]byte(input))
return mac.Sum(nil)
}
func encode(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
func normalizeUsername(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
func roleRank(role string) int {
switch role {
case RoleViewer:
return 1
case RoleAppOwner:
return 2
case RoleAdmin:
return 3
default:
return 0
}
}
func ValidRole(role string) bool { return roleRank(role) > 0 }