243 lines
6.6 KiB
Go
243 lines
6.6 KiB
Go
package publichttp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/mail"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"galaxy/authsession/internal/service/confirmemailcode"
|
|
"galaxy/authsession/internal/service/sendemailcode"
|
|
"galaxy/authsession/internal/service/shared"
|
|
"galaxy/authsession/internal/telemetry"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
|
)
|
|
|
|
const jsonContentType = "application/json; charset=utf-8"
|
|
|
|
const publicHTTPServiceName = "galaxy-authsession-public"
|
|
|
|
type sendEmailCodeRequest struct {
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
type sendEmailCodeResponse struct {
|
|
ChallengeID string `json:"challenge_id"`
|
|
}
|
|
|
|
type confirmEmailCodeRequest struct {
|
|
ChallengeID string `json:"challenge_id"`
|
|
Code string `json:"code"`
|
|
ClientPublicKey string `json:"client_public_key"`
|
|
}
|
|
|
|
type confirmEmailCodeResponse struct {
|
|
DeviceSessionID string `json:"device_session_id"`
|
|
}
|
|
|
|
type errorResponse struct {
|
|
Error errorBody `json:"error"`
|
|
}
|
|
|
|
type errorBody struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
var configureGinModeOnce sync.Once
|
|
|
|
func newHandlerWithConfig(cfg Config, deps Dependencies) (http.Handler, error) {
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
normalizedDeps, err := normalizeDependencies(deps)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
configureGinModeOnce.Do(func() {
|
|
gin.SetMode(gin.ReleaseMode)
|
|
})
|
|
|
|
engine := gin.New()
|
|
engine.Use(newOTelMiddleware(normalizedDeps.Telemetry))
|
|
engine.Use(withPublicObservability(normalizedDeps.Logger, normalizedDeps.Telemetry))
|
|
engine.POST(
|
|
"/api/v1/public/auth/send-email-code",
|
|
handleSendEmailCode(normalizedDeps.SendEmailCode, cfg.RequestTimeout),
|
|
)
|
|
engine.POST(
|
|
"/api/v1/public/auth/confirm-email-code",
|
|
handleConfirmEmailCode(normalizedDeps.ConfirmEmailCode, cfg.RequestTimeout),
|
|
)
|
|
|
|
return engine, nil
|
|
}
|
|
|
|
func newOTelMiddleware(runtime *telemetry.Runtime) gin.HandlerFunc {
|
|
options := []otelgin.Option{}
|
|
if runtime != nil {
|
|
options = append(
|
|
options,
|
|
otelgin.WithTracerProvider(runtime.TracerProvider()),
|
|
otelgin.WithMeterProvider(runtime.MeterProvider()),
|
|
)
|
|
}
|
|
|
|
return otelgin.Middleware(publicHTTPServiceName, options...)
|
|
}
|
|
|
|
func handleSendEmailCode(useCase SendEmailCodeUseCase, timeout time.Duration) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var request sendEmailCodeRequest
|
|
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
|
abortWithProjection(c, projectSendEmailCodeError(shared.InvalidRequest(err.Error())))
|
|
return
|
|
}
|
|
if err := validateSendEmailCodeRequest(&request); err != nil {
|
|
abortWithProjection(c, projectSendEmailCodeError(shared.InvalidRequest(err.Error())))
|
|
return
|
|
}
|
|
|
|
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
|
defer cancel()
|
|
|
|
result, err := useCase.Execute(callCtx, sendemailcode.Input{Email: request.Email})
|
|
if err != nil {
|
|
abortWithProjection(c, projectSendEmailCodeError(err))
|
|
return
|
|
}
|
|
if err := validateSendEmailCodeResult(&result); err != nil {
|
|
abortWithProjection(c, unavailableProjection(fmt.Errorf("send email code response: %w", err)))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, sendEmailCodeResponse{ChallengeID: result.ChallengeID})
|
|
}
|
|
}
|
|
|
|
func handleConfirmEmailCode(useCase ConfirmEmailCodeUseCase, timeout time.Duration) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var request confirmEmailCodeRequest
|
|
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
|
abortWithProjection(c, projectConfirmEmailCodeError(shared.InvalidRequest(err.Error())))
|
|
return
|
|
}
|
|
if err := validateConfirmEmailCodeRequest(&request); err != nil {
|
|
abortWithProjection(c, projectConfirmEmailCodeError(shared.InvalidRequest(err.Error())))
|
|
return
|
|
}
|
|
|
|
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
|
defer cancel()
|
|
|
|
result, err := useCase.Execute(callCtx, confirmemailcode.Input{
|
|
ChallengeID: request.ChallengeID,
|
|
Code: request.Code,
|
|
ClientPublicKey: request.ClientPublicKey,
|
|
})
|
|
if err != nil {
|
|
abortWithProjection(c, projectConfirmEmailCodeError(err))
|
|
return
|
|
}
|
|
if err := validateConfirmEmailCodeResult(&result); err != nil {
|
|
abortWithProjection(c, unavailableProjection(fmt.Errorf("confirm email code response: %w", err)))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, confirmEmailCodeResponse{DeviceSessionID: result.DeviceSessionID})
|
|
}
|
|
}
|
|
|
|
func validateSendEmailCodeRequest(request *sendEmailCodeRequest) error {
|
|
request.Email = strings.TrimSpace(request.Email)
|
|
if request.Email == "" {
|
|
return errors.New("email must not be empty")
|
|
}
|
|
|
|
parsedAddress, err := mail.ParseAddress(request.Email)
|
|
if err != nil || parsedAddress.Name != "" || parsedAddress.Address != request.Email {
|
|
return errors.New("email must be a single valid email address")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateSendEmailCodeResult(result *sendemailcode.Result) error {
|
|
result.ChallengeID = strings.TrimSpace(result.ChallengeID)
|
|
if result.ChallengeID == "" {
|
|
return errors.New("challenge_id must not be empty")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateConfirmEmailCodeRequest(request *confirmEmailCodeRequest) error {
|
|
request.ChallengeID = strings.TrimSpace(request.ChallengeID)
|
|
if request.ChallengeID == "" {
|
|
return errors.New("challenge_id must not be empty")
|
|
}
|
|
|
|
request.Code = strings.TrimSpace(request.Code)
|
|
if request.Code == "" {
|
|
return errors.New("code must not be empty")
|
|
}
|
|
|
|
request.ClientPublicKey = strings.TrimSpace(request.ClientPublicKey)
|
|
if request.ClientPublicKey == "" {
|
|
return errors.New("client_public_key must not be empty")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateConfirmEmailCodeResult(result *confirmemailcode.Result) error {
|
|
result.DeviceSessionID = strings.TrimSpace(result.DeviceSessionID)
|
|
if result.DeviceSessionID == "" {
|
|
return errors.New("device_session_id must not be empty")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func projectSendEmailCodeError(err error) shared.PublicErrorProjection {
|
|
if isTimeoutOrCanceled(err) {
|
|
return unavailableProjection(err)
|
|
}
|
|
|
|
projection := shared.ProjectPublicError(err)
|
|
if !shared.IsSendEmailCodePublicErrorCode(projection.Code) {
|
|
return unavailableProjection(err)
|
|
}
|
|
|
|
return projection
|
|
}
|
|
|
|
func projectConfirmEmailCodeError(err error) shared.PublicErrorProjection {
|
|
if isTimeoutOrCanceled(err) {
|
|
return unavailableProjection(err)
|
|
}
|
|
|
|
projection := shared.ProjectPublicError(err)
|
|
if !shared.IsConfirmEmailCodePublicErrorCode(projection.Code) {
|
|
return unavailableProjection(err)
|
|
}
|
|
|
|
return projection
|
|
}
|
|
|
|
func unavailableProjection(err error) shared.PublicErrorProjection {
|
|
return shared.ProjectPublicError(shared.ServiceUnavailable(err))
|
|
}
|
|
|
|
func isTimeoutOrCanceled(err error) bool {
|
|
return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)
|
|
}
|