add: implement shortlink

This commit is contained in:
mario
2025-05-13 15:44:11 +07:00
parent 2687a761cc
commit dd784da232
8 changed files with 565 additions and 1 deletions

View File

@@ -33,6 +33,12 @@ type Config struct {
EnableDatabaseAuth bool `mapstructure:"enable_database_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 {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`

View File

@@ -19,6 +19,11 @@ auth:
refresh_token_expiry: 168 # hours (7 days)
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:
host: "localhost"
port: 3306

View 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)
}

View File

@@ -59,3 +59,54 @@ type RefreshRequest struct {
type RefreshResponse struct {
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"`
}

View File

@@ -55,7 +55,7 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler {
// Initialize JWT auth service
jwtSecret := cfg.Auth.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
@@ -66,6 +66,15 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler {
jwtManager := auth.NewJWTManager(jwtSecret, accessExpiry, refreshExpiry)
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
r.Group(func(r chi.Router) {
// Health check
@@ -77,6 +86,10 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler {
r.Post("/login", authHandler.Login)
r.Post("/refresh", authHandler.RefreshToken)
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
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
r.Route("/dicomWeb", func(r chi.Router) {
// Add audit logging middleware to DICOM routes

View 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
}

View File

@@ -170,3 +170,13 @@ func (m *JWTManager) ValidateToken(tokenString string) (*CustomClaims, error) {
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
}

View 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