563c91e7d5
Resolves issues with gcp cloud logging quirks such as field names
218 lines
5.9 KiB
Go
218 lines
5.9 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/flowy-live/llink/internal/utils/flog"
|
|
|
|
firebaseauth "firebase.google.com/go/v4/auth"
|
|
"github.com/redis/go-redis/v9"
|
|
"go.jetify.com/typeid"
|
|
|
|
pbaero "github.com/flowy-live/llink/genproto/aero"
|
|
"github.com/flowy-live/llink/internal/utils"
|
|
)
|
|
|
|
const (
|
|
sessionExpiry = time.Hour * 24 * 15
|
|
extendSessionThreshold = time.Hour * 24 * 3
|
|
codeExpiry = time.Minute * 15
|
|
)
|
|
|
|
var (
|
|
ErrInvalidCode = errors.New("invalid code")
|
|
ErrSessionNotFound = errors.New("session not found")
|
|
)
|
|
|
|
type sessionTokenPrefix struct{}
|
|
|
|
func (sessionTokenPrefix) Prefix() string { return "session" }
|
|
|
|
type sessionToken struct {
|
|
typeid.TypeID[sessionTokenPrefix]
|
|
}
|
|
|
|
func newSessionToken() (sessionToken, error) {
|
|
return typeid.New[sessionToken]()
|
|
}
|
|
|
|
type Session struct {
|
|
Email string `json:"email"`
|
|
HumanId string `json:"human_id"`
|
|
}
|
|
|
|
type AuthService interface {
|
|
SessionReader
|
|
|
|
// RequestSignInCode emails a one-time code; the client redeems it via VerifySignInCode.
|
|
RequestSignInCode(ctx context.Context, email string) error
|
|
// VerifySignInCode returns ErrInvalidCode on a wrong code, otherwise creates
|
|
// a session keyed to (email, humanId).
|
|
VerifySignInCode(ctx context.Context, email, code, humanId string) (sessionToken string, err error)
|
|
// ExtendSession returns ErrSessionNotFound if no valid session.
|
|
ExtendSession(ctx context.Context, sessionToken string) error
|
|
SignOut(ctx context.Context, sessionToken string) error
|
|
|
|
// MintFirebaseCustomToken issues a Firebase custom token with uid=humanId and no claims.
|
|
MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error)
|
|
|
|
IsSystemAdmin(ctx context.Context, email string) bool
|
|
}
|
|
|
|
type authServiceImpl struct {
|
|
*sessionReaderImpl
|
|
aeroSvc pbaero.PrimaryClient
|
|
fbAuth *firebaseauth.Client
|
|
}
|
|
|
|
func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient, fbAuth *firebaseauth.Client) AuthService {
|
|
return &authServiceImpl{
|
|
sessionReaderImpl: newSessionReader(redisClient),
|
|
aeroSvc: aeroSvc,
|
|
fbAuth: fbAuth,
|
|
}
|
|
}
|
|
|
|
func (a *authServiceImpl) MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error) {
|
|
if humanId == "" {
|
|
return "", errors.New("humanId is required")
|
|
}
|
|
return a.fbAuth.CustomToken(ctx, humanId)
|
|
}
|
|
|
|
func (a *authServiceImpl) IsSystemAdmin(ctx context.Context, email string) bool {
|
|
formattedEmail, err := utils.NormalizeEmail(email)
|
|
if err != nil {
|
|
flog.Error("problem validating email", "error", err)
|
|
return false
|
|
}
|
|
|
|
if strings.Contains(formattedEmail, "@flowylabs.ai") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *authServiceImpl) RequestSignInCode(ctx context.Context, email string) error {
|
|
if email == "" {
|
|
return errors.New("email is required")
|
|
}
|
|
|
|
code := utils.RandomStringNumbers(4)
|
|
|
|
formattedEmail, err := utils.NormalizeEmail(email)
|
|
if err != nil {
|
|
return errors.New("email is not valid")
|
|
}
|
|
|
|
err = a.redisClient.Set(ctx, formattedEmail, code, codeExpiry).Err()
|
|
if err != nil {
|
|
flog.Error("error setting code in redis", "error", err)
|
|
return fmt.Errorf("error storing sign-in code: %w", err)
|
|
}
|
|
|
|
message := fmt.Sprintf("Here is your one-time code for signing into Flowy: %s\n\nPlease do not share this with anyone.\n\nBest, \nFlowy Team", code)
|
|
subject := fmt.Sprint("Sign In - Your One-Time Code for Flowy.llink")
|
|
_, err = a.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
|
|
ToEmails: []string{formattedEmail},
|
|
Subject: subject,
|
|
TemplateData: &pbaero.ShootEmailRequest_SimpleTextData{
|
|
SimpleTextData: &pbaero.SimpleTextData{
|
|
Message: message,
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("an error occurred while sending the email: %w", err)
|
|
}
|
|
|
|
flog.Info("sent sign in code", "email", formattedEmail)
|
|
return nil
|
|
}
|
|
|
|
func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code, humanId string) (string, error) {
|
|
formattedEmail, err := utils.NormalizeEmail(email)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid email: %w", err)
|
|
}
|
|
|
|
storedCode, err := a.redisClient.Get(ctx, formattedEmail).Result()
|
|
if err != nil {
|
|
if errors.Is(err, redis.Nil) {
|
|
return "", ErrInvalidCode
|
|
}
|
|
flog.Error("error getting code from redis", "error", err)
|
|
return "", fmt.Errorf("error verifying code: %w", err)
|
|
}
|
|
|
|
if storedCode != code {
|
|
return "", ErrInvalidCode
|
|
}
|
|
|
|
if err := a.redisClient.Del(ctx, formattedEmail).Err(); err != nil {
|
|
flog.Error("error deleting code from redis", "error", err)
|
|
}
|
|
|
|
token, err := a.createSession(ctx, formattedEmail, humanId)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
func (a *authServiceImpl) ExtendSession(ctx context.Context, token string) error {
|
|
ttl, err := a.redisClient.TTL(ctx, token).Result()
|
|
if err != nil {
|
|
return fmt.Errorf("error checking session TTL: %w", err)
|
|
}
|
|
|
|
if ttl < 0 {
|
|
return ErrSessionNotFound
|
|
}
|
|
|
|
if ttl < extendSessionThreshold {
|
|
if err := a.redisClient.Expire(ctx, token, sessionExpiry).Err(); err != nil {
|
|
return fmt.Errorf("error extending session: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *authServiceImpl) SignOut(ctx context.Context, token string) error {
|
|
if err := a.redisClient.Del(ctx, token).Err(); err != nil {
|
|
return fmt.Errorf("error deleting session: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *authServiceImpl) createSession(ctx context.Context, email, humanId string) (string, error) {
|
|
formattedEmail, err := utils.NormalizeEmail(email)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid email: %w", err)
|
|
}
|
|
|
|
token, err := newSessionToken()
|
|
if err != nil {
|
|
return "", fmt.Errorf("error generating session token: %w", err)
|
|
}
|
|
|
|
session := Session{Email: formattedEmail, HumanId: humanId}
|
|
data, err := json.Marshal(session)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error marshaling session: %w", err)
|
|
}
|
|
|
|
if err := a.redisClient.Set(ctx, token.String(), data, sessionExpiry).Err(); err != nil {
|
|
return "", fmt.Errorf("error storing session: %w", err)
|
|
}
|
|
|
|
return token.String(), nil
|
|
}
|