140 lines
3.4 KiB
Go
140 lines
3.4 KiB
Go
// Package local provides small in-process runtime implementations for
|
|
// authsession ports that do not require network dependencies.
|
|
package local
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"math/big"
|
|
"strings"
|
|
"time"
|
|
|
|
"galaxy/authsession/internal/domain/common"
|
|
"galaxy/authsession/internal/ports"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
challengeIDPrefix = "challenge-"
|
|
deviceSessionIDPrefix = "device-session-"
|
|
codeDigits = 6
|
|
)
|
|
|
|
// Clock implements ports.Clock using the local system clock in UTC.
|
|
type Clock struct{}
|
|
|
|
// Now returns the current system time normalized to UTC.
|
|
func (Clock) Now() time.Time {
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
// IDGenerator implements ports.IDGenerator with cryptographically random
|
|
// opaque identifiers.
|
|
type IDGenerator struct{}
|
|
|
|
// NewChallengeID returns a fresh random challenge identifier.
|
|
func (IDGenerator) NewChallengeID() (common.ChallengeID, error) {
|
|
value, err := newOpaqueIDString(challengeIDPrefix)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return common.ChallengeID(value), nil
|
|
}
|
|
|
|
// NewDeviceSessionID returns a fresh random device-session identifier.
|
|
func (IDGenerator) NewDeviceSessionID() (common.DeviceSessionID, error) {
|
|
value, err := newOpaqueIDString(deviceSessionIDPrefix)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return common.DeviceSessionID(value), nil
|
|
}
|
|
|
|
// CodeGenerator implements ports.CodeGenerator with random 6-digit decimal
|
|
// confirmation codes.
|
|
type CodeGenerator struct{}
|
|
|
|
// Generate returns one fresh random 6-digit decimal code.
|
|
func (CodeGenerator) Generate() (string, error) {
|
|
var builder strings.Builder
|
|
builder.Grow(codeDigits)
|
|
|
|
for idx := 0; idx < codeDigits; idx++ {
|
|
digit, err := rand.Int(rand.Reader, big.NewInt(10))
|
|
if err != nil {
|
|
return "", fmt.Errorf("generate confirmation code: %w", err)
|
|
}
|
|
builder.WriteByte(byte('0' + digit.Int64()))
|
|
}
|
|
|
|
return builder.String(), nil
|
|
}
|
|
|
|
// CodeHasher implements ports.CodeHasher with bcrypt-backed hashes.
|
|
type CodeHasher struct{}
|
|
|
|
// Hash returns the bcrypt hash of code.
|
|
func (CodeHasher) Hash(code string) ([]byte, error) {
|
|
if err := validateCode(code); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hash confirmation code: %w", err)
|
|
}
|
|
|
|
return hash, nil
|
|
}
|
|
|
|
// Compare reports whether hash matches code.
|
|
func (CodeHasher) Compare(hash []byte, code string) (bool, error) {
|
|
if err := validateCode(code); err != nil {
|
|
return false, err
|
|
}
|
|
if len(hash) == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
err := bcrypt.CompareHashAndPassword(hash, []byte(code))
|
|
switch err {
|
|
case nil:
|
|
return true, nil
|
|
case bcrypt.ErrMismatchedHashAndPassword:
|
|
return false, nil
|
|
default:
|
|
return false, fmt.Errorf("compare confirmation code hash: %w", err)
|
|
}
|
|
}
|
|
|
|
func newOpaqueIDString(prefix string) (string, error) {
|
|
randomBytes := make([]byte, 16)
|
|
if _, err := rand.Read(randomBytes); err != nil {
|
|
return "", fmt.Errorf("generate opaque identifier: %w", err)
|
|
}
|
|
|
|
return prefix + base64.RawURLEncoding.EncodeToString(randomBytes), nil
|
|
}
|
|
|
|
func validateCode(code string) error {
|
|
switch {
|
|
case strings.TrimSpace(code) == "":
|
|
return fmt.Errorf("code must not be empty")
|
|
case strings.TrimSpace(code) != code:
|
|
return fmt.Errorf("code must not contain surrounding whitespace")
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var (
|
|
_ ports.Clock = Clock{}
|
|
_ ports.IDGenerator = IDGenerator{}
|
|
_ ports.CodeGenerator = CodeGenerator{}
|
|
_ ports.CodeHasher = CodeHasher{}
|
|
)
|