324 lines
9.6 KiB
Go
324 lines
9.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
"go.b0esche.cloud/backend/internal/database"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
ChallengeLength = 32
|
|
RPID = "b0esche.cloud"
|
|
RPName = "b0esche Cloud"
|
|
Origin = "https://b0esche.cloud"
|
|
)
|
|
|
|
type Service struct {
|
|
db *database.DB
|
|
}
|
|
|
|
func NewService(db *database.DB) *Service {
|
|
return &Service{db: db}
|
|
}
|
|
|
|
// StartRegistrationChallenge creates a challenge for passkey registration
|
|
func (s *Service) StartRegistrationChallenge(ctx context.Context, userID uuid.UUID) (string, error) {
|
|
challenge := make([]byte, ChallengeLength)
|
|
if _, err := rand.Read(challenge); err != nil {
|
|
return "", fmt.Errorf("failed to generate challenge: %w", err)
|
|
}
|
|
|
|
challengeStr := base64.StdEncoding.EncodeToString(challenge)
|
|
|
|
// Store challenge in database
|
|
if err := s.db.CreateAuthChallenge(ctx, userID, challenge, "registration"); err != nil {
|
|
return "", fmt.Errorf("failed to store challenge: %w", err)
|
|
}
|
|
|
|
return challengeStr, nil
|
|
}
|
|
|
|
// StartAuthenticationChallenge creates a challenge for passkey authentication
|
|
func (s *Service) StartAuthenticationChallenge(ctx context.Context, username string) (string, []string, error) {
|
|
// Get user by username
|
|
user, err := s.db.GetUserByUsername(ctx, username)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("user not found: %w", err)
|
|
}
|
|
|
|
challenge := make([]byte, ChallengeLength)
|
|
if _, err := rand.Read(challenge); err != nil {
|
|
return "", nil, fmt.Errorf("failed to generate challenge: %w", err)
|
|
}
|
|
|
|
challengeStr := base64.StdEncoding.EncodeToString(challenge)
|
|
|
|
// Store challenge in database
|
|
if err := s.db.CreateAuthChallenge(ctx, user.ID, challenge, "authentication"); err != nil {
|
|
return "", nil, fmt.Errorf("failed to store challenge: %w", err)
|
|
}
|
|
|
|
// Get user's credentials
|
|
credentials, err := s.db.GetUserCredentials(ctx, user.ID)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("failed to get credentials: %w", err)
|
|
}
|
|
|
|
// Return credential IDs (base64 encoded for transport)
|
|
var credentialIDs []string
|
|
for _, cred := range credentials {
|
|
credentialIDs = append(credentialIDs, base64.StdEncoding.EncodeToString(cred.CredentialID))
|
|
}
|
|
|
|
return challengeStr, credentialIDs, nil
|
|
}
|
|
|
|
// VerifyRegistrationResponse verifies the attestation response from the client
|
|
func (s *Service) VerifyRegistrationResponse(
|
|
ctx context.Context,
|
|
userID uuid.UUID,
|
|
challengeB64 string,
|
|
credentialIDBase64 string,
|
|
publicKeyBase64 string,
|
|
clientDataJSON string,
|
|
attestationObjectBase64 string,
|
|
) (*database.Credential, error) {
|
|
// Decode inputs
|
|
challenge, err := base64.StdEncoding.DecodeString(challengeB64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid challenge encoding: %w", err)
|
|
}
|
|
|
|
credentialID, err := base64.StdEncoding.DecodeString(credentialIDBase64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid credential ID encoding: %w", err)
|
|
}
|
|
|
|
publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyBase64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid public key encoding: %w", err)
|
|
}
|
|
|
|
_, err = base64.StdEncoding.DecodeString(attestationObjectBase64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid attestation object encoding: %w", err)
|
|
}
|
|
|
|
// Verify challenge exists and belongs to this user
|
|
if err := s.verifyChallenge(ctx, userID, challenge, "registration"); err != nil {
|
|
return nil, fmt.Errorf("challenge verification failed: %w", err)
|
|
}
|
|
|
|
// In production, you would parse and verify the attestation object here
|
|
// For now, we'll just verify the client data matches
|
|
var clientData struct {
|
|
Type string `json:"type"`
|
|
Challenge string `json:"challenge"`
|
|
Origin string `json:"origin"`
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(clientDataJSON), &clientData); err != nil {
|
|
return nil, fmt.Errorf("invalid client data JSON: %w", err)
|
|
}
|
|
|
|
// Verify challenge in client data
|
|
clientDataChallenge, err := base64.StdEncoding.DecodeString(clientData.Challenge)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid challenge in client data: %w", err)
|
|
}
|
|
|
|
// Verify challenge matches (we skip the hash verification since it's not needed for API validation)
|
|
// clientDataHash := sha256.Sum256([]byte(clientDataJSON))
|
|
|
|
// Verify challenge matches
|
|
if !byteArraysEqual(clientDataChallenge, challenge) {
|
|
return nil, fmt.Errorf("challenge mismatch")
|
|
}
|
|
|
|
// Verify origin
|
|
if clientData.Origin != Origin {
|
|
return nil, fmt.Errorf("origin mismatch: expected %s, got %s", Origin, clientData.Origin)
|
|
}
|
|
|
|
// Verify type
|
|
if clientData.Type != "webauthn.create" {
|
|
return nil, fmt.Errorf("invalid client data type: %s", clientData.Type)
|
|
}
|
|
|
|
// Store credential in database
|
|
credential := &database.Credential{
|
|
ID: base64.StdEncoding.EncodeToString(credentialID),
|
|
UserID: userID,
|
|
CredentialPublicKey: publicKeyBytes,
|
|
CredentialID: credentialID,
|
|
SignCount: 0,
|
|
}
|
|
|
|
if err := s.db.CreateCredential(ctx, credential); err != nil {
|
|
return nil, fmt.Errorf("failed to store credential: %w", err)
|
|
}
|
|
|
|
// Mark challenge as used
|
|
if err := s.db.MarkChallengeUsed(ctx, challenge); err != nil {
|
|
return nil, fmt.Errorf("failed to mark challenge as used: %w", err)
|
|
}
|
|
|
|
return credential, nil
|
|
}
|
|
|
|
// VerifyAuthenticationResponse verifies the assertion response from the client
|
|
func (s *Service) VerifyAuthenticationResponse(
|
|
ctx context.Context,
|
|
username string,
|
|
challengeB64 string,
|
|
credentialIDBase64 string,
|
|
authenticatorData string,
|
|
clientDataJSON string,
|
|
signatureBase64 string,
|
|
) (*database.User, error) {
|
|
// Get user by username
|
|
user, err := s.db.GetUserByUsername(ctx, username)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("user not found: %w", err)
|
|
}
|
|
|
|
// Decode challenge
|
|
challenge, err := base64.StdEncoding.DecodeString(challengeB64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid challenge encoding: %w", err)
|
|
}
|
|
|
|
// Verify challenge
|
|
if err := s.verifyChallenge(ctx, user.ID, challenge, "authentication"); err != nil {
|
|
return nil, fmt.Errorf("challenge verification failed: %w", err)
|
|
}
|
|
|
|
// Decode credential ID
|
|
credentialID, err := base64.StdEncoding.DecodeString(credentialIDBase64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid credential ID encoding: %w", err)
|
|
}
|
|
|
|
// Get credential from database
|
|
credential, err := s.db.GetCredentialByID(ctx, credentialID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("credential not found: %w", err)
|
|
}
|
|
|
|
// Verify credential belongs to user
|
|
if credential.UserID != user.ID {
|
|
return nil, fmt.Errorf("credential does not belong to user")
|
|
}
|
|
|
|
// Parse and verify client data
|
|
var clientData struct {
|
|
Type string `json:"type"`
|
|
Challenge string `json:"challenge"`
|
|
Origin string `json:"origin"`
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(clientDataJSON), &clientData); err != nil {
|
|
return nil, fmt.Errorf("invalid client data JSON: %w", err)
|
|
}
|
|
|
|
// Verify challenge matches
|
|
clientDataChallenge, err := base64.StdEncoding.DecodeString(clientData.Challenge)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid challenge in client data: %w", err)
|
|
}
|
|
|
|
if !byteArraysEqual(clientDataChallenge, challenge) {
|
|
return nil, fmt.Errorf("challenge mismatch")
|
|
}
|
|
|
|
// Verify origin
|
|
if clientData.Origin != Origin {
|
|
return nil, fmt.Errorf("origin mismatch: expected %s, got %s", Origin, clientData.Origin)
|
|
}
|
|
|
|
// Verify type
|
|
if clientData.Type != "webauthn.get" {
|
|
return nil, fmt.Errorf("invalid client data type: %s", clientData.Type)
|
|
}
|
|
|
|
// In production, you would verify the signature here using the public key
|
|
// For now, we'll assume the signature is valid if we got this far
|
|
|
|
// Mark challenge as used
|
|
if err := s.db.MarkChallengeUsed(ctx, challenge); err != nil {
|
|
return nil, fmt.Errorf("failed to mark challenge as used: %w", err)
|
|
}
|
|
|
|
// Update credential last used time
|
|
if err := s.db.UpdateCredentialLastUsed(ctx, credential.ID); err != nil {
|
|
return nil, fmt.Errorf("failed to update credential last used: %w", err)
|
|
}
|
|
|
|
// Update user last login
|
|
if err := s.db.UpdateUserLastLogin(ctx, user.ID); err != nil {
|
|
return nil, fmt.Errorf("failed to update user last login: %w", err)
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (s *Service) verifyChallenge(ctx context.Context, userID uuid.UUID, challenge []byte, challengeType string) error {
|
|
return s.db.VerifyAuthChallenge(ctx, userID, challenge, challengeType)
|
|
}
|
|
|
|
func byteArraysEqual(a, b []byte) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// HashPassword hashes a password using bcrypt
|
|
func (s *Service) HashPassword(password string) (string, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
return string(hash), nil
|
|
}
|
|
|
|
// VerifyPassword checks if a password matches its hash
|
|
func (s *Service) VerifyPassword(passwordHash string, password string) bool {
|
|
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
|
|
return err == nil
|
|
}
|
|
|
|
// VerifyPasswordLogin verifies username and password credentials
|
|
func (s *Service) VerifyPasswordLogin(ctx context.Context, username, password string) (*database.User, error) {
|
|
user, err := s.db.GetUserByUsername(ctx, username)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("user not found: %w", err)
|
|
}
|
|
|
|
if user.PasswordHash == nil || *user.PasswordHash == "" {
|
|
return nil, fmt.Errorf("user does not have a password set")
|
|
}
|
|
|
|
if !s.VerifyPassword(*user.PasswordHash, password) {
|
|
return nil, fmt.Errorf("invalid password")
|
|
}
|
|
|
|
// Update last login
|
|
if err := s.db.UpdateUserLastLogin(ctx, user.ID); err != nil {
|
|
return nil, fmt.Errorf("failed to update user last login: %w", err)
|
|
}
|
|
|
|
return user, nil
|
|
}
|