add: implement shortlink
This commit is contained in:
@@ -33,6 +33,12 @@ type Config struct {
|
|||||||
EnableDatabaseAuth bool `mapstructure:"enable_database_auth"`
|
EnableDatabaseAuth bool `mapstructure:"enable_database_auth"`
|
||||||
} `mapstructure:"auth"`
|
} `mapstructure:"auth"`
|
||||||
|
|
||||||
|
Shortlink struct {
|
||||||
|
BaseURL string `mapstructure:"base_url"` // Base URL for shortlinks (e.g., https://example.com)
|
||||||
|
DefaultExpiryHours int `mapstructure:"default_expiry_hours"` // Default expiry time in hours
|
||||||
|
MaxAttempts int `mapstructure:"max_attempts"` // Maximum failed attempts allowed
|
||||||
|
} `mapstructure:"shortlink"`
|
||||||
|
|
||||||
Database struct {
|
Database struct {
|
||||||
Host string `mapstructure:"host"`
|
Host string `mapstructure:"host"`
|
||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ auth:
|
|||||||
refresh_token_expiry: 168 # hours (7 days)
|
refresh_token_expiry: 168 # hours (7 days)
|
||||||
enable_database_auth: false # Set to true when ready to use database
|
enable_database_auth: false # Set to true when ready to use database
|
||||||
|
|
||||||
|
shortlink:
|
||||||
|
base_url: "http://localhost:3333" # The base URL for generated OHIF Auth shortlinks
|
||||||
|
default_expiry_hours: 24 # Default expiry time for shortlinks (1 day)
|
||||||
|
max_attempts: 5 # Maximum number of failed login attempts
|
||||||
|
|
||||||
database:
|
database:
|
||||||
host: "localhost"
|
host: "localhost"
|
||||||
port: 3306
|
port: 3306
|
||||||
|
|||||||
129
internal/api/handlers/shortlink.go
Normal file
129
internal/api/handlers/shortlink.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/middleware"
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models"
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/service"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShortLinkHandler handles shortlink operations
|
||||||
|
type ShortLinkHandler struct {
|
||||||
|
logger *zap.Logger
|
||||||
|
shortLinkService *service.ShortLinkService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewShortLinkHandler creates a new shortlink handler
|
||||||
|
func NewShortLinkHandler(logger *zap.Logger, shortLinkService *service.ShortLinkService) *ShortLinkHandler {
|
||||||
|
return &ShortLinkHandler{
|
||||||
|
logger: logger,
|
||||||
|
shortLinkService: shortLinkService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateShortLink handles shortlink generation requests
|
||||||
|
func (h *ShortLinkHandler) GenerateShortLink(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Only allow admin or expertise_doctor roles to generate shortlinks
|
||||||
|
userRole, ok := r.Context().Value(middleware.UserRoleKey).(string)
|
||||||
|
if !ok || (userRole != "admin" && userRole != "expertise_doctor") {
|
||||||
|
h.logger.Warn("Unauthorized attempt to generate shortlink",
|
||||||
|
zap.String("role", userRole))
|
||||||
|
http.Error(w, "Only admin or expertise doctor can generate short links", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
var req models.GenerateShortLinkRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.logger.Error("Failed to parse shortlink generation request", zap.Error(err))
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user ID from context
|
||||||
|
userID, ok := r.Context().Value(middleware.UserIDKey).(string)
|
||||||
|
if !ok {
|
||||||
|
h.logger.Error("User ID not found in context")
|
||||||
|
http.Error(w, "User context not found", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate shortlink using configured baseURL from service
|
||||||
|
response, err := h.shortLinkService.GenerateShortLink(&req, userID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to generate shortlink",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("patientID", req.PatientID),
|
||||||
|
zap.String("studyUID", req.StudyUID))
|
||||||
|
|
||||||
|
statusCode := http.StatusInternalServerError
|
||||||
|
message := "Failed to generate shortlink"
|
||||||
|
|
||||||
|
if err == service.ErrInvalidStudyUID {
|
||||||
|
statusCode = http.StatusBadRequest
|
||||||
|
message = "Invalid StudyInstanceUID"
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, message, statusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log successful shortlink generation
|
||||||
|
h.logger.Info("Shortlink generated successfully",
|
||||||
|
zap.String("token", response.ShortToken),
|
||||||
|
zap.String("patientID", req.PatientID),
|
||||||
|
zap.String("studyUID", req.StudyUID),
|
||||||
|
zap.String("createdBy", userID))
|
||||||
|
|
||||||
|
// Return response
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortLinkAuth handles authentication requests using shortlinks
|
||||||
|
func (h *ShortLinkHandler) ShortLinkAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Parse request body
|
||||||
|
var req models.ShortLinkAuthRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.logger.Error("Failed to parse shortlink auth request", zap.Error(err))
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and authenticate
|
||||||
|
response, err := h.shortLinkService.AuthenticateWithShortLink(&req)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("Shortlink authentication failed",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("token", req.ShortToken))
|
||||||
|
|
||||||
|
statusCode := http.StatusUnauthorized
|
||||||
|
message := "Authentication failed"
|
||||||
|
|
||||||
|
switch err {
|
||||||
|
case service.ErrShortLinkNotFound, service.ErrShortLinkExpired:
|
||||||
|
message = "Short link not found or expired"
|
||||||
|
case service.ErrInvalidDOB:
|
||||||
|
message = "Invalid date of birth"
|
||||||
|
case service.ErrTooManyAttempts:
|
||||||
|
message = "Too many failed attempts"
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, message, statusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log successful authentication
|
||||||
|
h.logger.Info("Shortlink authentication successful",
|
||||||
|
zap.String("token", req.ShortToken))
|
||||||
|
|
||||||
|
// Return response
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
@@ -59,3 +59,54 @@ type RefreshRequest struct {
|
|||||||
type RefreshResponse struct {
|
type RefreshResponse struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShortLink represents a short URL token for patient access
|
||||||
|
type ShortLink struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
Token string `db:"token" json:"token"` // The short token used in the URL
|
||||||
|
PatientID string `db:"patient_id" json:"patient_id"`
|
||||||
|
StudyUID string `db:"study_uid" json:"study_uid"` // The StudyInstanceUID this token grants access to
|
||||||
|
HashedDOB string `db:"hashed_dob" json:"-"` // Hashed Date of Birth for verification
|
||||||
|
ExpiresAt string `db:"expires_at" json:"expires_at"`
|
||||||
|
IsRevoked bool `db:"is_revoked" json:"is_revoked"`
|
||||||
|
CreatedAt string `db:"created_at" json:"created_at"`
|
||||||
|
CreatedByID string `db:"created_by_id" json:"created_by_id"` // ID of admin who created this
|
||||||
|
RemainingTries int `db:"remaining_tries" json:"-"` // Number of failed attempts allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateShortLinkRequest represents request to create a short URL
|
||||||
|
type GenerateShortLinkRequest struct {
|
||||||
|
PatientID string `json:"patient_id"`
|
||||||
|
StudyUID string `json:"study_uid"`
|
||||||
|
DOB string `json:"dob"` // Date of birth in YYYY-MM-DD format
|
||||||
|
ExpiresIn int `json:"expires_in"` // Expiry in hours (optional, defaults to 72)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateShortLinkResponse is the response for a generated short link
|
||||||
|
type GenerateShortLinkResponse struct {
|
||||||
|
ShortToken string `json:"short_token"`
|
||||||
|
FullURL string `json:"full_url"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortLinkAuthRequest represents the shortlink authentication request
|
||||||
|
type ShortLinkAuthRequest struct {
|
||||||
|
ShortToken string `json:"short_token,omitempty"` // The original field
|
||||||
|
ShortTokenAlt string `json:"shortToken,omitempty"` // Support for camelCase naming from OHIF
|
||||||
|
DOB string `json:"dob"` // Date of birth in YYYY-MM-DD format
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ShortLinkAuthRequest) GetToken() string {
|
||||||
|
// Use ShortTokenAlt if ShortToken is empty
|
||||||
|
if r.ShortToken == "" {
|
||||||
|
return r.ShortTokenAlt
|
||||||
|
}
|
||||||
|
return r.ShortToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortLinkAuthResponse is the response for successful shortlink authentication
|
||||||
|
type ShortLinkAuthResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"` // Token expiry in seconds
|
||||||
|
RedirectURL string `json:"redirect_url"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler {
|
|||||||
// Initialize JWT auth service
|
// Initialize JWT auth service
|
||||||
jwtSecret := cfg.Auth.JWTSecret
|
jwtSecret := cfg.Auth.JWTSecret
|
||||||
if jwtSecret == "" {
|
if jwtSecret == "" {
|
||||||
jwtSecret = "vQ6PQqUyh7pBNOytClgN+Nw1XBq7F8Qo6VP3VwIqvHY=" // Default from your example, should be set in config
|
logger.Warn("JWT secret not provided in config")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert config values to time.Duration
|
// Convert config values to time.Duration
|
||||||
@@ -66,6 +66,15 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler {
|
|||||||
jwtManager := auth.NewJWTManager(jwtSecret, accessExpiry, refreshExpiry)
|
jwtManager := auth.NewJWTManager(jwtSecret, accessExpiry, refreshExpiry)
|
||||||
authService := service.NewAuthService(jwtManager)
|
authService := service.NewAuthService(jwtManager)
|
||||||
|
|
||||||
|
// Initialize shortlink service with config values
|
||||||
|
shortLinkService := service.NewShortLinkService(
|
||||||
|
jwtManager,
|
||||||
|
logger,
|
||||||
|
cfg.Shortlink.BaseURL,
|
||||||
|
cfg.Shortlink.DefaultExpiryHours,
|
||||||
|
cfg.Shortlink.MaxAttempts,
|
||||||
|
)
|
||||||
|
|
||||||
// Public routes that don't require authentication
|
// Public routes that don't require authentication
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
// Health check
|
// Health check
|
||||||
@@ -77,6 +86,10 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler {
|
|||||||
r.Post("/login", authHandler.Login)
|
r.Post("/login", authHandler.Login)
|
||||||
r.Post("/refresh", authHandler.RefreshToken)
|
r.Post("/refresh", authHandler.RefreshToken)
|
||||||
r.Post("/logout", authHandler.Logout)
|
r.Post("/logout", authHandler.Logout)
|
||||||
|
|
||||||
|
// ShortLink authentication - no auth required
|
||||||
|
shortLinkHandler := handlers.NewShortLinkHandler(logger, shortLinkService)
|
||||||
|
r.Post("/shortlink", shortLinkHandler.ShortLinkAuth)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -85,6 +98,10 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler {
|
|||||||
// Apply authentication middleware
|
// Apply authentication middleware
|
||||||
r.Use(apiMiddleware.Auth(authService, logger))
|
r.Use(apiMiddleware.Auth(authService, logger))
|
||||||
|
|
||||||
|
// Shortlink generation - only for admin and expertise_doctor roles
|
||||||
|
shortLinkHandler := handlers.NewShortLinkHandler(logger, shortLinkService)
|
||||||
|
r.Post("/generate-link", shortLinkHandler.GenerateShortLink)
|
||||||
|
|
||||||
// DICOM Web routes
|
// DICOM Web routes
|
||||||
r.Route("/dicomWeb", func(r chi.Router) {
|
r.Route("/dicomWeb", func(r chi.Router) {
|
||||||
// Add audit logging middleware to DICOM routes
|
// Add audit logging middleware to DICOM routes
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -170,3 +170,13 @@ func (m *JWTManager) ValidateToken(tokenString string) (*CustomClaims, error) {
|
|||||||
|
|
||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAccessExpiry returns the configured access token expiry duration
|
||||||
|
func (m *JWTManager) GetAccessExpiry() time.Duration {
|
||||||
|
return m.accessExpiry
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRefreshExpiry returns the configured refresh token expiry duration
|
||||||
|
func (m *JWTManager) GetRefreshExpiry() time.Duration {
|
||||||
|
return m.refreshExpiry
|
||||||
|
}
|
||||||
|
|||||||
50
test/http/shortlink-flow.http
Normal file
50
test/http/shortlink-flow.http
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
### Shortlink Authentication Test File
|
||||||
|
@baseUrl = http://localhost:5555
|
||||||
|
@adminToken = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMSIsImVtYWlsIjoiYWRtaW4iLCJyb2xlIjoiZXhwZXJ0aXNlX2RvY3RvciIsInVzZXJfbmFtZSI6IkFkbWluIFVzZXIiLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiaG9tZV91cmwiOiIvIiwic3R1ZHlfbGlzdCI6ImVuYWJsZWQiLCJleHAiOjE3NDcyMDY5NTAsImlhdCI6MTc0NzEyMDU1MH0.M3fhlKB8MX-NxGdEgnaA9-AhMXnXjUjRsWYOBXntJe4
|
||||||
|
|
||||||
|
### 0. Login as Admin and take the token
|
||||||
|
POST http://localhost:5555/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "admin",
|
||||||
|
"password": "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
### 1. Generate Short Link (Admin/Expertise Doctor Only)
|
||||||
|
POST {{baseUrl}}/generate-link
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {{adminToken}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"patient_id": "MR00000359",
|
||||||
|
"study_uid": "1.2.826.0.1.3680043.9.7307.1.202503196393.01",
|
||||||
|
"dob": "2001-10-07",
|
||||||
|
"expires_in": 48
|
||||||
|
}
|
||||||
|
|
||||||
|
### 2. Authenticate with Short Link and DOB
|
||||||
|
# Use the short_token from the response of request #1
|
||||||
|
POST {{baseUrl}}/auth/shortlink
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"short_token": "oP-tLWeu",
|
||||||
|
"dob": "2001-10-07"
|
||||||
|
}
|
||||||
|
|
||||||
|
### 3. Try authentication with incorrect DOB
|
||||||
|
POST {{baseUrl}}/auth/shortlink
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"short_token": "erB5xT5S",
|
||||||
|
"dob": "1985-04-16"
|
||||||
|
}
|
||||||
|
|
||||||
|
### 4. Access DICOM data with the JWT from successful shortlink auth
|
||||||
|
# Replace with token from successful auth in request #2
|
||||||
|
GET {{baseUrl}}/dicomWeb/studies/1.2.826.0.1.3680043.9.7307.1.202503196393.01
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2hvcnRsaW5rX3NsXzgzZGNmNzQ1NGYwZiIsImVtYWlsIjoicGF0aWVudF9zbF84M2RjZjc0NTRmMGZAc2hvcnRsaW5rLmxvY2FsIiwicm9sZSI6InBhdGllbnQiLCJ1c2VyX25hbWUiOiJQYXRpZW50IiwidG9rZW5fdHlwZSI6ImFjY2VzcyIsInBhdGllbnRfaWQiOiJNUjAwMDAwMzU5IiwicGF0aWVudF9uYW1lIjoiUGF0aWVudCIsInN0dWR5X2l1aWRzIjpbIjEuMi44MjYuMC4xLjM2ODAwNDMuOS43MzA3LjEuMjAyNTAzMTk2MzkzLjAxIl0sImhvbWVfdXJsIjoidmlld2VyP1N0dWR5SW5zdGFuY2VVSURzPTEuMi44MjYuMC4xLjM2ODAwNDMuOS43MzA3LjEuMjAyNTAzMTk2MzkzLjAxIiwic3R1ZHlfbGlzdCI6ImRpc2FibGVkIiwiZXhwIjoxNzQ3MjA3MDI4LCJpYXQiOjE3NDcxMjA2Mjh9.RMGF9ParYAmOXbJqd0DP2kl0X6O0n8j_LI6FF9el4qM
|
||||||
Reference in New Issue
Block a user