* refactor: update api and client to reference humanIds * fix: prevent deletion of network member This may cause various side effects if there is data in other services which reference this member
216 lines
5.9 KiB
Go
216 lines
5.9 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/flowy-live/llink/genproto/aero"
|
|
"github.com/flowy-live/llink/internal/utils"
|
|
"github.com/redis/go-redis/v9"
|
|
"go.jetify.com/typeid"
|
|
)
|
|
|
|
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 {
|
|
// RequestSignInCode generates a code and emails it to the provided email.
|
|
// To retrieve a session, client must verify with VerifySignInCode.
|
|
RequestSignInCode(ctx context.Context, email string) error
|
|
// VerifySignInCode returns ErrInvalidCode if incorrect code, otherwise creates a session.
|
|
// humanId is stored in the session alongside the email.
|
|
VerifySignInCode(ctx context.Context, email, code, humanId string) (sessionToken string, err error)
|
|
// GetSession returns ErrSessionNotFound if no valid session
|
|
GetSession(ctx context.Context, sessionToken string) (*Session, error)
|
|
// ExtendSession returns ErrSessionNotFound if no valid session
|
|
ExtendSession(ctx context.Context, sessionToken string) error
|
|
SignOut(ctx context.Context, sessionToken string) error
|
|
|
|
IsSystemAdmin(ctx context.Context, email string) bool
|
|
}
|
|
|
|
type authServiceImpl struct {
|
|
redisClient *redis.Client
|
|
aeroSvc pbaero.PrimaryClient
|
|
}
|
|
|
|
func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient) AuthService {
|
|
return &authServiceImpl{redisClient: redisClient, aeroSvc: aeroSvc}
|
|
}
|
|
|
|
func (a *authServiceImpl) IsSystemAdmin(ctx context.Context, email string) bool {
|
|
formattedEmail, err := utils.NormalizeEmail(email)
|
|
if err != nil {
|
|
slog.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 {
|
|
slog.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.Sprintf("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)
|
|
}
|
|
|
|
slog.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
|
|
}
|
|
slog.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 {
|
|
slog.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) GetSession(ctx context.Context, token string) (*Session, error) {
|
|
sessionInfo, err := a.redisClient.Get(ctx, token).Result()
|
|
if err != nil {
|
|
if errors.Is(err, redis.Nil) {
|
|
return nil, ErrSessionNotFound
|
|
}
|
|
return nil, fmt.Errorf("error getting session: %w", err)
|
|
}
|
|
|
|
var session Session
|
|
err = json.Unmarshal([]byte(sessionInfo), &session)
|
|
|
|
return &session, 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
|
|
}
|