A community based topic aggregation platform built on atproto
1package verification 2 3import ( 4 "context" 5 "crypto/rand" 6 "crypto/subtle" 7 "fmt" 8 "math/big" 9 "regexp" 10 "time" 11 12 "golang.org/x/crypto/bcrypt" 13) 14 15const ( 16 OTPLength = 6 17 OTPExpiryMinutes = 10 18 MaxAttempts = 3 19 VerificationTTL = 365 * 24 * time.Hour // 1 year 20 21 // Rate limits 22 RateLimitPerPhone = 3 // Max 3 requests per phone per hour 23 RateLimitPerDID = 5 // Max 5 requests per DID per day 24 PhoneRateWindow = 1 * time.Hour 25 DIDRateWindow = 24 * time.Hour 26) 27 28var ( 29 // E.164 phone number regex (basic validation) 30 e164Regex = regexp.MustCompile(`^\+[1-9]\d{1,14}$`) 31) 32 33// verificationService implements VerificationService 34type verificationService struct { 35 repo VerificationRepository 36 smsProvider SMSProvider 37 signer SignatureService 38 pdsWriter PDSWriter 39 hashProvider PhoneHashProvider 40} 41 42// PhoneHashProvider handles phone number hashing 43type PhoneHashProvider interface { 44 HashPhone(phoneNumber string) string 45} 46 47// NewVerificationService creates a new verification service 48func NewVerificationService( 49 repo VerificationRepository, 50 smsProvider SMSProvider, 51 signer SignatureService, 52 pdsWriter PDSWriter, 53 hashProvider PhoneHashProvider, 54) VerificationService { 55 return &verificationService{ 56 repo: repo, 57 smsProvider: smsProvider, 58 signer: signer, 59 pdsWriter: pdsWriter, 60 hashProvider: hashProvider, 61 } 62} 63 64// RequestPhoneVerification sends an OTP code to the provided phone number 65func (s *verificationService) RequestPhoneVerification(ctx context.Context, did, phoneNumber string) (*VerificationRequest, error) { 66 // Validate phone number format 67 if !e164Regex.MatchString(phoneNumber) { 68 return nil, &InvalidPhoneNumberError{Phone: phoneNumber} 69 } 70 71 phoneHash := s.hashProvider.HashPhone(phoneNumber) 72 73 // Check if phone is already verified by another account 74 existingVerification, err := s.repo.GetVerificationByPhoneHash(ctx, phoneHash) 75 if err == nil && existingVerification != nil && existingVerification.DID != did { 76 return nil, &PhoneAlreadyVerifiedError{PhoneHash: phoneHash} 77 } 78 79 // Check rate limits (per phone) 80 phoneExceeded, err := s.repo.CheckRateLimit(ctx, "phone:"+phoneHash, RateLimitPerPhone, PhoneRateWindow) 81 if err != nil { 82 return nil, fmt.Errorf("failed to check phone rate limit: %w", err) 83 } 84 if phoneExceeded { 85 // Log audit event 86 _ = s.repo.LogAuditEvent(ctx, &AuditEvent{ 87 DID: &did, 88 EventType: "rate_limit_hit", 89 PhoneHash: &phoneHash, 90 Metadata: map[string]interface{}{"limit_type": "phone"}, 91 CreatedAt: time.Now(), 92 }) 93 return nil, &RateLimitExceededError{Identifier: "phone", RetryAfter: 3600} 94 } 95 96 // Check rate limits (per DID) 97 didExceeded, err := s.repo.CheckRateLimit(ctx, "did:"+did, RateLimitPerDID, DIDRateWindow) 98 if err != nil { 99 return nil, fmt.Errorf("failed to check DID rate limit: %w", err) 100 } 101 if didExceeded { 102 _ = s.repo.LogAuditEvent(ctx, &AuditEvent{ 103 DID: &did, 104 EventType: "rate_limit_hit", 105 PhoneHash: &phoneHash, 106 Metadata: map[string]interface{}{"limit_type": "did"}, 107 CreatedAt: time.Now(), 108 }) 109 return nil, &RateLimitExceededError{Identifier: "did", RetryAfter: 86400} 110 } 111 112 // Generate OTP code 113 otpCode, err := generateOTP(OTPLength) 114 if err != nil { 115 return nil, fmt.Errorf("failed to generate OTP: %w", err) 116 } 117 118 // Hash OTP code for storage 119 otpHash, err := bcrypt.GenerateFromPassword([]byte(otpCode), bcrypt.DefaultCost) 120 if err != nil { 121 return nil, fmt.Errorf("failed to hash OTP: %w", err) 122 } 123 124 // Create verification request 125 req := &VerificationRequest{ 126 RequestID: generateRequestID(), 127 DID: did, 128 PhoneHash: phoneHash, 129 OTPCodeHash: string(otpHash), 130 Attempts: 0, 131 CreatedAt: time.Now(), 132 ExpiresAt: time.Now().Add(OTPExpiryMinutes * time.Minute), 133 } 134 135 // Store request 136 if err := s.repo.StoreVerificationRequest(ctx, req); err != nil { 137 return nil, fmt.Errorf("failed to store verification request: %w", err) 138 } 139 140 // Send SMS 141 if err := s.smsProvider.SendOTP(ctx, phoneNumber, otpCode); err != nil { 142 // Log failed SMS delivery 143 _ = s.repo.LogAuditEvent(ctx, &AuditEvent{ 144 DID: &did, 145 EventType: "sms_delivery_failed", 146 PhoneHash: &phoneHash, 147 Metadata: map[string]interface{}{"error": err.Error()}, 148 CreatedAt: time.Now(), 149 }) 150 return nil, &SMSDeliveryFailedError{Reason: err.Error()} 151 } 152 153 // Record rate limit attempt 154 _ = s.repo.RecordRateLimitAttempt(ctx, "phone:"+phoneHash) 155 _ = s.repo.RecordRateLimitAttempt(ctx, "did:"+did) 156 157 // Log audit event 158 _ = s.repo.LogAuditEvent(ctx, &AuditEvent{ 159 DID: &did, 160 EventType: "request_sent", 161 PhoneHash: &phoneHash, 162 CreatedAt: time.Now(), 163 }) 164 165 return req, nil 166} 167 168// VerifyPhone validates the OTP code and writes verification to PDS 169func (s *verificationService) VerifyPhone(ctx context.Context, did, requestID, code string) (*VerificationResult, error) { 170 // Retrieve verification request 171 req, err := s.repo.GetVerificationRequest(ctx, requestID) 172 if err != nil { 173 return nil, &RequestNotFoundError{RequestID: requestID} 174 } 175 176 // Check if request belongs to this DID 177 if req.DID != did { 178 return nil, &RequestNotFoundError{RequestID: requestID} 179 } 180 181 // Check if expired 182 if time.Now().After(req.ExpiresAt) { 183 _ = s.repo.DeleteVerificationRequest(ctx, requestID) 184 return nil, &CodeExpiredError{} 185 } 186 187 // Check attempt limit 188 if req.Attempts >= MaxAttempts { 189 _ = s.repo.DeleteVerificationRequest(ctx, requestID) 190 return nil, &TooManyAttemptsError{} 191 } 192 193 // Verify OTP code (constant-time comparison) 194 err = bcrypt.CompareHashAndPassword([]byte(req.OTPCodeHash), []byte(code)) 195 if err != nil { 196 // Increment attempts 197 _ = s.repo.IncrementAttempts(ctx, requestID) 198 199 // Log failed verification 200 _ = s.repo.LogAuditEvent(ctx, &AuditEvent{ 201 DID: &did, 202 EventType: "verification_failed", 203 PhoneHash: &req.PhoneHash, 204 Metadata: map[string]interface{}{"attempts": req.Attempts + 1}, 205 CreatedAt: time.Now(), 206 }) 207 208 return nil, &InvalidCodeError{} 209 } 210 211 // Code is valid - create verification 212 now := time.Now() 213 expiresAt := now.Add(VerificationTTL) 214 215 // Create verification data for signing 216 verificationData := &VerificationData{ 217 Type: "phone", 218 VerifiedBy: s.signer.GetVerifierDID(), 219 VerifiedAt: now, 220 ExpiresAt: expiresAt, 221 SubjectDID: did, 222 } 223 224 // Sign verification 225 signature, err := s.signer.SignVerification(ctx, verificationData) 226 if err != nil { 227 return nil, fmt.Errorf("failed to sign verification: %w", err) 228 } 229 230 // Create signed verification for PDS 231 signedVerification := &SignedVerification{ 232 Type: verificationData.Type, 233 VerifiedBy: verificationData.VerifiedBy, 234 VerifiedAt: verificationData.VerifiedAt.Format(time.RFC3339), 235 ExpiresAt: verificationData.ExpiresAt.Format(time.RFC3339), 236 Signature: signature, 237 } 238 239 // Write to PDS profile 240 if err := s.pdsWriter.WriteVerificationToProfile(ctx, did, signedVerification); err != nil { 241 return nil, &PDSWriteFailedError{DID: did, Reason: err.Error()} 242 } 243 244 // Store verification in AppView database 245 phoneVerification := &PhoneVerification{ 246 DID: did, 247 PhoneHash: req.PhoneHash, 248 VerifiedAt: now, 249 ExpiresAt: expiresAt, 250 } 251 if err := s.repo.StoreVerification(ctx, phoneVerification); err != nil { 252 return nil, fmt.Errorf("failed to store verification: %w", err) 253 } 254 255 // Clean up request 256 _ = s.repo.DeleteVerificationRequest(ctx, requestID) 257 258 // Log successful verification 259 _ = s.repo.LogAuditEvent(ctx, &AuditEvent{ 260 DID: &did, 261 EventType: "verification_success", 262 PhoneHash: &req.PhoneHash, 263 CreatedAt: time.Now(), 264 }) 265 266 return &VerificationResult{ 267 Verified: true, 268 VerifiedAt: now, 269 ExpiresAt: expiresAt, 270 }, nil 271} 272 273// GetVerificationStatus retrieves current verification status for a user 274func (s *verificationService) GetVerificationStatus(ctx context.Context, did string) (*VerificationStatus, error) { 275 verification, err := s.repo.GetVerification(ctx, did) 276 if err != nil || verification == nil { 277 return &VerificationStatus{ 278 HasVerifiedPhone: false, 279 }, nil 280 } 281 282 // Check if verification has expired 283 if time.Now().After(verification.ExpiresAt) { 284 return &VerificationStatus{ 285 HasVerifiedPhone: false, 286 }, nil 287 } 288 289 // Check if renewal needed (within 30 days of expiry) 290 needsRenewal := time.Until(verification.ExpiresAt) < 30*24*time.Hour 291 292 return &VerificationStatus{ 293 HasVerifiedPhone: true, 294 VerifiedAt: &verification.VerifiedAt, 295 ExpiresAt: &verification.ExpiresAt, 296 NeedsRenewal: needsRenewal, 297 }, nil 298} 299 300// CheckPhoneAvailability checks if phone number is already verified 301func (s *verificationService) CheckPhoneAvailability(ctx context.Context, phoneNumber string) (bool, error) { 302 phoneHash := s.hashProvider.HashPhone(phoneNumber) 303 verification, err := s.repo.GetVerificationByPhoneHash(ctx, phoneHash) 304 if err != nil { 305 return false, err 306 } 307 return verification == nil, nil 308} 309 310// generateOTP generates a cryptographically secure numeric OTP 311func generateOTP(length int) (string, error) { 312 max := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(length)), nil) 313 n, err := rand.Int(rand.Reader, max) 314 if err != nil { 315 return "", err 316 } 317 return fmt.Sprintf("%0*d", length, n), nil 318} 319 320// generateRequestID generates a unique request ID 321func generateRequestID() string { 322 b := make([]byte, 16) 323 rand.Read(b) 324 return fmt.Sprintf("%x", b) 325}