@@ -3,9 +3,13 @@ package service
33import (
44 "context"
55 "crypto/rand"
6+ "crypto/sha256"
7+ "crypto/subtle"
68 "encoding/base64"
79 "encoding/hex"
810 "errors"
11+ "fmt"
12+ "math/big"
913 "strings"
1014 "time"
1115
2630 ErrPasswordTooShort = errors .New ("password_too_short" )
2731 ErrEmailInvalid = errors .New ("email_invalid" )
2832 ErrResetTokenInvalid = errors .New ("reset_token_invalid" )
33+ ErrOTPInvalid = errors .New ("otp_invalid" )
34+ ErrNotSteward = errors .New ("not_steward" )
2935)
3036
3137type AuthService struct {
@@ -180,6 +186,82 @@ func (s *AuthService) ConfirmPasswordReset(token, newPassword string) error {
180186 return nil
181187}
182188
189+ // RequestStewardOTP generates a 6-digit code, stores its hash, and emails the
190+ // plain code. Silent on unknown email or non-steward user to avoid leaking
191+ // who is/isn't elevated. Codes expire in 10 minutes.
192+ func (s * AuthService ) RequestStewardOTP (ctx context.Context , emailAddr string ) error {
193+ emailAddr = strings .ToLower (strings .TrimSpace (emailAddr ))
194+ user , err := s .users .FindByEmail (emailAddr )
195+ if err != nil {
196+ return nil // silent
197+ }
198+ if ! user .IsSteward () {
199+ return nil // silent — don't reveal role
200+ }
201+
202+ code , err := newOTPCode ()
203+ if err != nil {
204+ return err
205+ }
206+ hash := hashOTP (code )
207+ expiresAt := time .Now ().Add (10 * time .Minute )
208+
209+ if err := s .users .SetOTP (user .ID , hash , expiresAt ); err != nil {
210+ return err
211+ }
212+
213+ if s .mailer != nil && s .mailer .Configured () {
214+ _ = s .mailer .SendStewardOTP (ctx , user .Email , code )
215+ }
216+
217+ audit .Record (s .db , user .ID .String (), "steward_otp_requested" , user .ID .String (), nil )
218+ return nil
219+ }
220+
221+ // VerifyStewardOTP checks the code in constant time, issues a session if it
222+ // matches and is unexpired, and clears the code so it can't be replayed.
223+ func (s * AuthService ) VerifyStewardOTP (ctx context.Context , emailAddr , code string ) (* SessionToken , error ) {
224+ emailAddr = strings .ToLower (strings .TrimSpace (emailAddr ))
225+ code = strings .TrimSpace (code )
226+ user , err := s .users .FindByEmail (emailAddr )
227+ if err != nil {
228+ return nil , ErrOTPInvalid
229+ }
230+ if ! user .IsSteward () {
231+ return nil , ErrNotSteward
232+ }
233+ if user .OTPCodeHash == "" || user .OTPExpiresAt == nil || time .Now ().After (* user .OTPExpiresAt ) {
234+ return nil , ErrOTPInvalid
235+ }
236+ expected , got := []byte (user .OTPCodeHash ), []byte (hashOTP (code ))
237+ if subtle .ConstantTimeCompare (expected , got ) != 1 {
238+ return nil , ErrOTPInvalid
239+ }
240+ _ = s .users .ClearOTP (user .ID )
241+
242+ st , err := s .issueSession (user )
243+ if err != nil {
244+ return nil , err
245+ }
246+ audit .Record (s .db , user .ID .String (), "steward_otp_verified" , user .ID .String (), nil )
247+ return st , nil
248+ }
249+
250+ // newOTPCode returns a 6-digit numeric code (zero-padded). Uses crypto/rand
251+ // so each code is unpredictable.
252+ func newOTPCode () (string , error ) {
253+ n , err := rand .Int (rand .Reader , big .NewInt (1_000_000 ))
254+ if err != nil {
255+ return "" , err
256+ }
257+ return fmt .Sprintf ("%06d" , n .Int64 ()), nil
258+ }
259+
260+ func hashOTP (code string ) string {
261+ sum := sha256 .Sum256 ([]byte (code ))
262+ return hex .EncodeToString (sum [:])
263+ }
264+
183265func (s * AuthService ) issueSession (user * models.User ) (* SessionToken , error ) {
184266 raw , err := newToken ()
185267 if err != nil {
0 commit comments