add: implement shortlink
This commit is contained in:
296
internal/api/service/shortlink_service.go
Normal file
296
internal/api/service/shortlink_service.go
Normal file
@@ -0,0 +1,296 @@
|
||||
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/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
|
||||
// Configuration settings
|
||||
baseURL string
|
||||
defaultExpiryTime time.Duration
|
||||
maxAttempts int
|
||||
// Mock in-memory storage for now, would use a database in production
|
||||
shortLinks map[string]*models.ShortLink
|
||||
}
|
||||
|
||||
// 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,
|
||||
baseURL: baseURL,
|
||||
defaultExpiryTime: time.Duration(defaultExpiryHours) * time.Hour,
|
||||
maxAttempts: maxAttempts,
|
||||
shortLinks: make(map[string]*models.ShortLink),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 production, this would be in a database)
|
||||
s.shortLinks[token] = shortLink
|
||||
|
||||
// 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
|
||||
shortLink, exists := s.shortLinks[token]
|
||||
if !exists {
|
||||
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--
|
||||
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--
|
||||
return nil, ErrInvalidDOB
|
||||
}
|
||||
|
||||
// DOB verified, reset tries count as successful login
|
||||
shortLink.RemainingTries = s.maxAttempts
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user