security: Add proper authentication, RBAC, and tenant isolation
- Add password hashing with bcrypt - Add AuthService with proper login - Add password strength validation - Add RBAC middleware (AdminOnly, ManagerOrAdmin) - Add tenant isolation middleware - Update CRM handler with tenant filtering - Add JWT fallback for development mode - Add user context helpers - Build successful
This commit is contained in:
@@ -38,6 +38,16 @@ func (c Claims) Valid() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasRole kontrollerar om användaren har en specifik roll
|
||||
func (c Claims) HasRole(role string) bool {
|
||||
for _, r := range c.Roles {
|
||||
if r == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Context Key ────────────────────────────────────────────────────────────
|
||||
type contextKey int
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HashPassword skapar en bcrypt hash av lösenordet
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// VerifyPassword kontrollerar att lösenordet matchar hashen
|
||||
func VerifyPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ValidatePasswordStrength kontrollerar lösenordsstyrka
|
||||
func ValidatePasswordStrength(password string) error {
|
||||
if len(password) < 8 {
|
||||
return fmt.Errorf("password must be at least 8 characters")
|
||||
}
|
||||
|
||||
hasUpper := false
|
||||
hasLower := false
|
||||
hasNumber := false
|
||||
hasSpecial := false
|
||||
|
||||
for _, c := range password {
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z':
|
||||
hasUpper = true
|
||||
case c >= 'a' && c <= 'z':
|
||||
hasLower = true
|
||||
case c >= '0' && c <= '9':
|
||||
hasNumber = true
|
||||
case c >= '!' && c <= '/' || c >= ':' && c <= '@' || c >= '[' && c <= '`' || c >= '{' && c <= '~':
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasUpper {
|
||||
return fmt.Errorf("password must contain at least one uppercase letter")
|
||||
}
|
||||
if !hasLower {
|
||||
return fmt.Errorf("password must contain at least one lowercase letter")
|
||||
}
|
||||
if !hasNumber {
|
||||
return fmt.Errorf("password must contain at least one number")
|
||||
}
|
||||
if !hasSpecial {
|
||||
return fmt.Errorf("password must contain at least one special character")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// AuthService hanterar autentisering och auktorisation
|
||||
type AuthService struct {
|
||||
db *sql.DB
|
||||
jwtSecret string
|
||||
issuer string
|
||||
audience string
|
||||
}
|
||||
|
||||
// NewAuthService skapar en ny auth service
|
||||
func NewAuthService(db *sql.DB, jwtSecret, issuer, audience string) *AuthService {
|
||||
return &AuthService{
|
||||
db: db,
|
||||
jwtSecret: jwtSecret,
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
}
|
||||
}
|
||||
|
||||
// User representerar en autentiserad användare
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
|
||||
// LoginRequest innehåller login-uppgifter
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// LoginResponse innehåller token och användardata
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
// Login autentiserar en användare och returnerar JWT token
|
||||
func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||
// Hämta användare från databas
|
||||
var user User
|
||||
var passwordHash string
|
||||
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, email, name, role, tenant_id, password_hash
|
||||
FROM boc_users
|
||||
WHERE email = $1 AND status = 'active'
|
||||
`, req.Email).Scan(&user.ID, &user.Email, &user.Name, &user.Role, &user.TenantID, &passwordHash)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("invalid email or password")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
// Verifiera lösenord
|
||||
if !VerifyPassword(req.Password, passwordHash) {
|
||||
return nil, fmt.Errorf("invalid email or password")
|
||||
}
|
||||
|
||||
// Generera JWT token
|
||||
token, err := s.GenerateToken(user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate token: %w", err)
|
||||
}
|
||||
|
||||
// Uppdatera last_login
|
||||
_, _ = s.db.ExecContext(ctx, `
|
||||
UPDATE boc_users SET last_login = NOW() WHERE id = $1
|
||||
`, user.ID)
|
||||
|
||||
return &LoginResponse{
|
||||
Token: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600, // 1 timme
|
||||
User: user,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateToken skapar en JWT token för en användare
|
||||
func (s *AuthService) GenerateToken(user User) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"tenant_id": user.TenantID,
|
||||
"iss": s.issuer,
|
||||
"aud": s.audience,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(1 * time.Hour).Unix(), // 1 timme
|
||||
"jti": generateJTI(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.jwtSecret))
|
||||
}
|
||||
|
||||
// ValidateToken validerar en JWT token och returnerar användardata
|
||||
func (s *AuthService) ValidateToken(tokenString string) (*User, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(s.jwtSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid token: %w", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, fmt.Errorf("token is invalid")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid claims")
|
||||
}
|
||||
|
||||
user := &User{
|
||||
ID: getStringClaim(claims, "sub"),
|
||||
Email: getStringClaim(claims, "email"),
|
||||
Name: getStringClaim(claims, "name"),
|
||||
Role: getStringClaim(claims, "role"),
|
||||
TenantID: getStringClaim(claims, "tenant_id"),
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// HasRole kontrollerar om användaren har en specifik roll
|
||||
func (s *AuthService) HasRole(user *User, role string) bool {
|
||||
if user.Role == role {
|
||||
return true
|
||||
}
|
||||
for _, r := range user.Roles {
|
||||
if r == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RequireRole middleware kontrollerar att användaren har en specifik roll
|
||||
func (s *AuthService) RequireRole(role string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := FromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user := &User{
|
||||
ID: claims.Sub,
|
||||
Email: claims.Email,
|
||||
Role: "",
|
||||
Roles: claims.Roles,
|
||||
}
|
||||
|
||||
if !s.HasRole(user, role) {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func generateJTI() string {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
Reference in New Issue
Block a user