225 lines
6.8 KiB
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 }
|