328 lines
9.5 KiB
Go
328 lines
9.5 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models"
|
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/repository"
|
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/auth"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const (
|
|
// DefaultShortLinkExpiry is the default expiration time for short links (72 hours)
|
|
DefaultShortLinkExpiry = 72 * time.Hour
|
|
|
|
// DefaultMaxTries is the default number of login attempts allowed for a shortlink
|
|
DefaultMaxTries = 5
|
|
|
|
// ShortTokenLength is the length of the generated short token
|
|
ShortTokenLength = 8
|
|
)
|
|
|
|
var (
|
|
ErrShortLinkNotFound = errors.New("short link not found or expired")
|
|
ErrInvalidDOB = errors.New("invalid date of birth")
|
|
ErrShortLinkExpired = errors.New("short link has expired")
|
|
ErrTooManyAttempts = errors.New("too many failed attempts")
|
|
ErrCreationFailed = errors.New("failed to create short link")
|
|
ErrInvalidStudyUID = errors.New("invalid or missing StudyInstanceUID")
|
|
ErrAdminRoleRequired = errors.New("admin role required to generate shortlinks")
|
|
)
|
|
|
|
// ShortLinkService handles operations related to short links
|
|
type ShortLinkService struct {
|
|
jwtManager *auth.JWTManager
|
|
logger *zap.Logger
|
|
shortLinkRepo *repository.ShortLinkRepository
|
|
patientRepo *repository.PatientRepository
|
|
// Configuration settings
|
|
baseURL string
|
|
defaultExpiryTime time.Duration
|
|
maxAttempts int
|
|
}
|
|
|
|
// NewShortLinkService creates a new short link service
|
|
func NewShortLinkService(jwtManager *auth.JWTManager, logger *zap.Logger, baseURL string, defaultExpiryHours int, maxAttempts int) *ShortLinkService {
|
|
// Set default values if not provided
|
|
if baseURL == "" {
|
|
baseURL = "http://localhost:3000"
|
|
}
|
|
|
|
if defaultExpiryHours <= 0 {
|
|
defaultExpiryHours = 72 // Default to 72 hours if not specified
|
|
}
|
|
|
|
if maxAttempts <= 0 {
|
|
maxAttempts = 5 // Default to 5 attempts if not specified
|
|
}
|
|
|
|
return &ShortLinkService{
|
|
jwtManager: jwtManager,
|
|
logger: logger,
|
|
shortLinkRepo: repository.NewShortLinkRepository(),
|
|
patientRepo: repository.NewPatientRepository(),
|
|
baseURL: baseURL,
|
|
defaultExpiryTime: time.Duration(defaultExpiryHours) * time.Hour,
|
|
maxAttempts: maxAttempts,
|
|
}
|
|
}
|
|
|
|
// GenerateShortLink creates a new short link for patient and study access
|
|
func (s *ShortLinkService) GenerateShortLink(req *models.GenerateShortLinkRequest, creatorID string) (*models.GenerateShortLinkResponse, error) {
|
|
// Validate inputs
|
|
if req.PatientID == "" {
|
|
return nil, errors.New("patient ID is required")
|
|
}
|
|
if req.StudyUID == "" {
|
|
return nil, ErrInvalidStudyUID
|
|
}
|
|
if req.DOB == "" {
|
|
return nil, errors.New("date of birth is required")
|
|
}
|
|
|
|
// Normalize DOB format (ensure YYYY-MM-DD)
|
|
dob := normalizeDOB(req.DOB)
|
|
if !isValidDOBFormat(dob) {
|
|
return nil, errors.New("invalid date of birth format, expected YYYY-MM-DD")
|
|
}
|
|
|
|
// Set expiration if not provided
|
|
expiresIn := s.defaultExpiryTime
|
|
if req.ExpiresIn > 0 {
|
|
expiresIn = time.Duration(req.ExpiresIn) * time.Hour
|
|
}
|
|
expiresAt := time.Now().Add(expiresIn)
|
|
|
|
// Generate a secure random token
|
|
token, err := generateSecureToken(ShortTokenLength)
|
|
if err != nil {
|
|
s.logger.Error("Failed to generate secure token", zap.Error(err))
|
|
return nil, ErrCreationFailed
|
|
}
|
|
|
|
// Hash the DOB for secure storage
|
|
hashedDOB := hashDOB(dob)
|
|
|
|
// Create the short link record
|
|
shortLink := &models.ShortLink{
|
|
ID: generateID(),
|
|
Token: token,
|
|
PatientID: req.PatientID,
|
|
StudyUID: req.StudyUID,
|
|
HashedDOB: hashedDOB,
|
|
ExpiresAt: expiresAt.Format(time.RFC3339),
|
|
IsRevoked: false,
|
|
CreatedAt: time.Now().Format(time.RFC3339),
|
|
CreatedByID: creatorID,
|
|
RemainingTries: s.maxAttempts,
|
|
}
|
|
|
|
// Store the short link in the database
|
|
err = s.shortLinkRepo.CreateShortLink(shortLink)
|
|
if err != nil {
|
|
s.logger.Error("Failed to store shortlink in database", zap.Error(err))
|
|
return nil, ErrCreationFailed
|
|
}
|
|
|
|
// Generate the full URL using the configured base URL
|
|
fullURL := fmt.Sprintf("%s/short-auth?short=%s", s.baseURL, token)
|
|
|
|
return &models.GenerateShortLinkResponse{
|
|
ShortToken: token,
|
|
FullURL: fullURL,
|
|
ExpiresAt: shortLink.ExpiresAt,
|
|
}, nil
|
|
}
|
|
|
|
// ValidateShortLink validates a short link token and DOB
|
|
func (s *ShortLinkService) ValidateShortLink(req *models.ShortLinkAuthRequest) (*models.ShortLink, error) {
|
|
// Get the token using the helper method that handles both field names
|
|
token := req.GetToken()
|
|
|
|
// Find the short link in the database
|
|
shortLink, err := s.shortLinkRepo.GetShortLinkByToken(token)
|
|
if err != nil {
|
|
s.logger.Error("Error retrieving shortlink", zap.Error(err), zap.String("token", token))
|
|
return nil, ErrShortLinkNotFound
|
|
}
|
|
|
|
if shortLink == nil {
|
|
return nil, ErrShortLinkNotFound
|
|
}
|
|
|
|
// Check if expired
|
|
expiresAt, err := time.Parse(time.RFC3339, shortLink.ExpiresAt)
|
|
if err != nil || time.Now().After(expiresAt) {
|
|
return nil, ErrShortLinkExpired
|
|
}
|
|
|
|
// Check if revoked
|
|
if shortLink.IsRevoked {
|
|
return nil, ErrShortLinkNotFound
|
|
}
|
|
|
|
// Check remaining tries
|
|
if shortLink.RemainingTries <= 0 {
|
|
return nil, ErrTooManyAttempts
|
|
}
|
|
|
|
// Normalize and hash the provided DOB
|
|
dob := normalizeDOB(req.DOB)
|
|
if !isValidDOBFormat(dob) {
|
|
// Decrement remaining tries on invalid format
|
|
shortLink.RemainingTries--
|
|
|
|
// Update the shortlink in the database
|
|
err = s.shortLinkRepo.UpdateShortLink(shortLink)
|
|
if err != nil {
|
|
s.logger.Error("Failed to update shortlink tries", zap.Error(err))
|
|
}
|
|
|
|
return nil, errors.New("invalid date of birth format, expected YYYY-MM-DD")
|
|
}
|
|
|
|
hashedDOB := hashDOB(dob)
|
|
|
|
// Verify the DOB
|
|
if hashedDOB != shortLink.HashedDOB {
|
|
// Decrement remaining tries on failed verification
|
|
shortLink.RemainingTries--
|
|
|
|
// Update the shortlink in the database
|
|
err = s.shortLinkRepo.UpdateShortLink(shortLink)
|
|
if err != nil {
|
|
s.logger.Error("Failed to update shortlink tries", zap.Error(err))
|
|
}
|
|
|
|
return nil, ErrInvalidDOB
|
|
}
|
|
|
|
// DOB verified, reset tries count as successful login
|
|
shortLink.RemainingTries = s.maxAttempts
|
|
|
|
// Update the shortlink in the database
|
|
err = s.shortLinkRepo.UpdateShortLink(shortLink)
|
|
if err != nil {
|
|
s.logger.Error("Failed to update shortlink tries after successful validation", zap.Error(err))
|
|
}
|
|
|
|
return shortLink, nil
|
|
}
|
|
|
|
// AuthenticateWithShortLink authenticates a user using a short link and DOB
|
|
func (s *ShortLinkService) AuthenticateWithShortLink(req *models.ShortLinkAuthRequest) (*models.ShortLinkAuthResponse, error) {
|
|
// Validate the short link
|
|
shortLink, err := s.ValidateShortLink(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Determine patient name (could be fetched from a database)
|
|
patientName := "Patient" // Placeholder, in production get real name
|
|
|
|
// Create additional claims for the JWT
|
|
additionalClaims := make(map[string]interface{})
|
|
additionalClaims["patient_id"] = shortLink.PatientID
|
|
additionalClaims["patient_name"] = patientName
|
|
additionalClaims["study_iuids"] = []string{shortLink.StudyUID}
|
|
additionalClaims["home_url"] = fmt.Sprintf("viewer?StudyInstanceUIDs=%s", shortLink.StudyUID)
|
|
additionalClaims["study_list"] = "disabled"
|
|
|
|
// Generate JWT
|
|
// Using a virtual "user" for the patient with a patient role
|
|
userID := fmt.Sprintf("shortlink_%s", shortLink.ID)
|
|
email := fmt.Sprintf("patient_%s@shortlink.local", shortLink.ID) // Virtual email for JWT
|
|
role := "patient" // Always patient role for shortlinks
|
|
|
|
// Generate access token (24-hour validity for patient access)
|
|
accessToken, err := s.jwtManager.GenerateAccessToken(userID, email, role, patientName, additionalClaims)
|
|
if err != nil {
|
|
s.logger.Error("Failed to generate JWT for shortlink auth", zap.Error(err))
|
|
return nil, errors.New("authentication error")
|
|
}
|
|
|
|
// Create response
|
|
redirectURL := fmt.Sprintf("/viewer?StudyInstanceUIDs=%s", shortLink.StudyUID)
|
|
|
|
return &models.ShortLinkAuthResponse{
|
|
AccessToken: accessToken,
|
|
ExpiresIn: int(s.jwtManager.GetAccessExpiry().Seconds()),
|
|
RedirectURL: redirectURL,
|
|
}, nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
// hashDOB creates a secure hash of a date of birth
|
|
func hashDOB(dob string) string {
|
|
hash := sha256.Sum256([]byte(dob))
|
|
return hex.EncodeToString(hash[:])
|
|
}
|
|
|
|
// generateSecureToken generates a secure random token for short links
|
|
func generateSecureToken(length int) (string, error) {
|
|
b := make([]byte, length)
|
|
_, err := rand.Read(b)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.URLEncoding.EncodeToString(b)[:length], nil
|
|
}
|
|
|
|
// generateID generates a unique ID for a shortlink
|
|
func generateID() string {
|
|
b := make([]byte, 6)
|
|
rand.Read(b)
|
|
return fmt.Sprintf("sl_%s", hex.EncodeToString(b))
|
|
}
|
|
|
|
// normalizeDOB normalizes date of birth to YYYY-MM-DD format
|
|
func normalizeDOB(dob string) string {
|
|
// Remove any non-alphanumeric characters except dash
|
|
dob = strings.Map(func(r rune) rune {
|
|
if (r >= '0' && r <= '9') || r == '-' {
|
|
return r
|
|
}
|
|
return -1
|
|
}, dob)
|
|
|
|
return dob
|
|
}
|
|
|
|
// isValidDOBFormat checks if the DOB is in YYYY-MM-DD format
|
|
func isValidDOBFormat(dob string) bool {
|
|
// Check basic format
|
|
if len(dob) != 10 {
|
|
return false
|
|
}
|
|
|
|
// Check dashes
|
|
if dob[4] != '-' || dob[7] != '-' {
|
|
return false
|
|
}
|
|
|
|
// Parse year, month, day
|
|
yearStr := dob[0:4]
|
|
monthStr := dob[5:7]
|
|
dayStr := dob[8:10]
|
|
|
|
// Check if all are numeric
|
|
for _, ch := range yearStr + monthStr + dayStr {
|
|
if ch < '0' || ch > '9' {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Could add more validation here (leap years, month/day ranges)
|
|
|
|
return true
|
|
}
|