457 lines
13 KiB
Go
457 lines
13 KiB
Go
package restapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/mail"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
var errPublicAuthIdentityNotApplicable = errors.New("public auth identity does not apply to this route")
|
|
|
|
type malformedJSONRequestError struct {
|
|
message string
|
|
reason PublicMalformedRequestReason
|
|
}
|
|
|
|
func (e *malformedJSONRequestError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
|
|
return e.message
|
|
}
|
|
|
|
type publicAuthIdentity struct {
|
|
kind string
|
|
value string
|
|
}
|
|
|
|
// AuthServiceClient defines the consumer-side contract used by public auth
|
|
// REST handlers to delegate unauthenticated authentication commands to the
|
|
// Auth / Session Service.
|
|
type AuthServiceClient interface {
|
|
// SendEmailCode starts a login challenge for input.Email and returns the
|
|
// challenge identifier that the client must later confirm.
|
|
SendEmailCode(ctx context.Context, input SendEmailCodeInput) (SendEmailCodeResult, error)
|
|
|
|
// ConfirmEmailCode completes a previously issued challenge, registers
|
|
// input.ClientPublicKey for the new device session, and returns the created
|
|
// device session identifier.
|
|
ConfirmEmailCode(ctx context.Context, input ConfirmEmailCodeInput) (ConfirmEmailCodeResult, error)
|
|
}
|
|
|
|
// SendEmailCodeInput describes the public REST and adapter payload used to
|
|
// request a login code for a single e-mail address.
|
|
type SendEmailCodeInput struct {
|
|
// Email is the single client e-mail address that should receive the login
|
|
// code challenge.
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
// SendEmailCodeResult describes the public REST and adapter payload returned
|
|
// after the Auth / Session Service creates a login challenge.
|
|
type SendEmailCodeResult struct {
|
|
// ChallengeID identifies the issued challenge that must be confirmed by the
|
|
// client in the next public auth step.
|
|
ChallengeID string `json:"challenge_id"`
|
|
}
|
|
|
|
// ConfirmEmailCodeInput describes the public REST and adapter payload used to
|
|
// complete a previously issued login challenge.
|
|
type ConfirmEmailCodeInput struct {
|
|
// ChallengeID identifies the challenge previously returned by
|
|
// SendEmailCode.
|
|
ChallengeID string `json:"challenge_id"`
|
|
|
|
// Code is the verification code delivered to the client by the Auth /
|
|
// Session Service.
|
|
Code string `json:"code"`
|
|
|
|
// ClientPublicKey is the standard base64-encoded raw 32-byte Ed25519 public
|
|
// key that should be registered for the created device session.
|
|
ClientPublicKey string `json:"client_public_key"`
|
|
|
|
// TimeZone is the client-selected IANA time zone name forwarded to the
|
|
// Auth / Session Service as registration context for first-time user
|
|
// creation.
|
|
TimeZone string `json:"time_zone"`
|
|
}
|
|
|
|
// ConfirmEmailCodeResult describes the public REST and adapter payload
|
|
// returned after the Auth / Session Service creates a device session.
|
|
type ConfirmEmailCodeResult struct {
|
|
// DeviceSessionID is the stable identifier of the created device session.
|
|
DeviceSessionID string `json:"device_session_id"`
|
|
}
|
|
|
|
// AuthServiceError allows an auth adapter to project a stable public REST
|
|
// error without teaching the gateway transport layer about upstream business
|
|
// rules.
|
|
type AuthServiceError struct {
|
|
// StatusCode is the HTTP status that the public REST handler should expose.
|
|
StatusCode int
|
|
|
|
// Code is the stable edge-level error code written into the JSON envelope.
|
|
Code string
|
|
|
|
// Message is the human-readable client-safe error description.
|
|
Message string
|
|
}
|
|
|
|
// Error returns a readable representation of the projected auth service error.
|
|
func (e *AuthServiceError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
|
|
switch {
|
|
case strings.TrimSpace(e.Code) == "" && strings.TrimSpace(e.Message) == "":
|
|
return http.StatusText(e.normalizedStatusCode())
|
|
case strings.TrimSpace(e.Code) == "":
|
|
return e.Message
|
|
case strings.TrimSpace(e.Message) == "":
|
|
return e.Code
|
|
default:
|
|
return e.Code + ": " + e.Message
|
|
}
|
|
}
|
|
|
|
func (e *AuthServiceError) normalizedStatusCode() int {
|
|
if e == nil || e.StatusCode < 400 || e.StatusCode > 599 {
|
|
return http.StatusInternalServerError
|
|
}
|
|
|
|
return e.StatusCode
|
|
}
|
|
|
|
func (e *AuthServiceError) normalizedCode() string {
|
|
if e == nil {
|
|
return errorCodeInternalError
|
|
}
|
|
|
|
code := strings.TrimSpace(e.Code)
|
|
if code == "" {
|
|
switch e.normalizedStatusCode() {
|
|
case http.StatusServiceUnavailable:
|
|
return errorCodeServiceUnavailable
|
|
case http.StatusBadRequest:
|
|
return errorCodeInvalidRequest
|
|
default:
|
|
return errorCodeInternalError
|
|
}
|
|
}
|
|
|
|
return code
|
|
}
|
|
|
|
func (e *AuthServiceError) normalizedMessage() string {
|
|
if e == nil {
|
|
return "internal server error"
|
|
}
|
|
|
|
message := strings.TrimSpace(e.Message)
|
|
if message == "" {
|
|
switch e.normalizedStatusCode() {
|
|
case http.StatusServiceUnavailable:
|
|
return "auth service is unavailable"
|
|
case http.StatusBadRequest:
|
|
return "request is invalid"
|
|
default:
|
|
return "internal server error"
|
|
}
|
|
}
|
|
|
|
return message
|
|
}
|
|
|
|
// unavailableAuthServiceClient keeps the public auth surface mounted until a
|
|
// concrete upstream adapter is wired into the gateway process.
|
|
type unavailableAuthServiceClient struct{}
|
|
|
|
func (unavailableAuthServiceClient) SendEmailCode(context.Context, SendEmailCodeInput) (SendEmailCodeResult, error) {
|
|
return SendEmailCodeResult{}, &AuthServiceError{
|
|
StatusCode: http.StatusServiceUnavailable,
|
|
Code: errorCodeServiceUnavailable,
|
|
Message: "auth service is unavailable",
|
|
}
|
|
}
|
|
|
|
func (unavailableAuthServiceClient) ConfirmEmailCode(context.Context, ConfirmEmailCodeInput) (ConfirmEmailCodeResult, error) {
|
|
return ConfirmEmailCodeResult{}, &AuthServiceError{
|
|
StatusCode: http.StatusServiceUnavailable,
|
|
Code: errorCodeServiceUnavailable,
|
|
Message: "auth service is unavailable",
|
|
}
|
|
}
|
|
|
|
func handleSendEmailCode(authService AuthServiceClient, timeout time.Duration) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var input SendEmailCodeInput
|
|
if err := decodeJSONRequest(c.Request, &input); err != nil {
|
|
abortInvalidRequest(c, err.Error())
|
|
return
|
|
}
|
|
if err := validateSendEmailCodeInput(&input); err != nil {
|
|
abortInvalidRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
|
defer cancel()
|
|
|
|
result, err := authService.SendEmailCode(callCtx, input)
|
|
if err != nil {
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
abortWithError(c, http.StatusServiceUnavailable, errorCodeServiceUnavailable, "auth service is unavailable")
|
|
return
|
|
}
|
|
abortWithAuthServiceError(c, err)
|
|
return
|
|
}
|
|
if err := validateSendEmailCodeResult(&result); err != nil {
|
|
abortWithError(c, http.StatusInternalServerError, errorCodeInternalError, "internal server error")
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
}
|
|
|
|
func handleConfirmEmailCode(authService AuthServiceClient, timeout time.Duration) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var input ConfirmEmailCodeInput
|
|
if err := decodeJSONRequest(c.Request, &input); err != nil {
|
|
abortInvalidRequest(c, err.Error())
|
|
return
|
|
}
|
|
if err := validateConfirmEmailCodeInput(&input); err != nil {
|
|
abortInvalidRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
|
defer cancel()
|
|
|
|
result, err := authService.ConfirmEmailCode(callCtx, input)
|
|
if err != nil {
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
abortWithError(c, http.StatusServiceUnavailable, errorCodeServiceUnavailable, "auth service is unavailable")
|
|
return
|
|
}
|
|
abortWithAuthServiceError(c, err)
|
|
return
|
|
}
|
|
if err := validateConfirmEmailCodeResult(&result); err != nil {
|
|
abortWithError(c, http.StatusInternalServerError, errorCodeInternalError, "internal server error")
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
}
|
|
|
|
func abortInvalidRequest(c *gin.Context, message string) {
|
|
abortWithError(c, http.StatusBadRequest, errorCodeInvalidRequest, message)
|
|
}
|
|
|
|
func abortWithAuthServiceError(c *gin.Context, err error) {
|
|
var authErr *AuthServiceError
|
|
if errors.As(err, &authErr) {
|
|
abortWithError(c, authErr.normalizedStatusCode(), authErr.normalizedCode(), authErr.normalizedMessage())
|
|
return
|
|
}
|
|
|
|
abortWithError(c, http.StatusInternalServerError, errorCodeInternalError, "internal server error")
|
|
}
|
|
|
|
func decodeJSONRequest(r *http.Request, target any) error {
|
|
if r == nil || r.Body == nil {
|
|
return &malformedJSONRequestError{
|
|
message: "request body must not be empty",
|
|
reason: PublicMalformedRequestReasonEmptyBody,
|
|
}
|
|
}
|
|
|
|
return decodeJSONReader(r.Body, target)
|
|
}
|
|
|
|
func decodeJSONBytes(bodyBytes []byte, target any) error {
|
|
return decodeJSONReader(bytes.NewReader(bodyBytes), target)
|
|
}
|
|
|
|
func decodeJSONReader(reader io.Reader, target any) error {
|
|
decoder := json.NewDecoder(reader)
|
|
decoder.DisallowUnknownFields()
|
|
|
|
if err := decoder.Decode(target); err != nil {
|
|
return describeJSONDecodeError(err)
|
|
}
|
|
|
|
if err := decoder.Decode(&struct{}{}); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
|
|
return &malformedJSONRequestError{
|
|
message: "request body must contain a single JSON object",
|
|
reason: PublicMalformedRequestReasonMultipleJSONObjects,
|
|
}
|
|
}
|
|
|
|
return &malformedJSONRequestError{
|
|
message: "request body must contain a single JSON object",
|
|
reason: PublicMalformedRequestReasonMultipleJSONObjects,
|
|
}
|
|
}
|
|
|
|
func describeJSONDecodeError(err error) error {
|
|
var syntaxErr *json.SyntaxError
|
|
var typeErr *json.UnmarshalTypeError
|
|
|
|
switch {
|
|
case errors.Is(err, io.EOF):
|
|
return &malformedJSONRequestError{
|
|
message: "request body must not be empty",
|
|
reason: PublicMalformedRequestReasonEmptyBody,
|
|
}
|
|
case errors.As(err, &syntaxErr):
|
|
return &malformedJSONRequestError{
|
|
message: "request body contains malformed JSON",
|
|
reason: PublicMalformedRequestReasonMalformedJSON,
|
|
}
|
|
case errors.Is(err, io.ErrUnexpectedEOF):
|
|
return &malformedJSONRequestError{
|
|
message: "request body contains malformed JSON",
|
|
reason: PublicMalformedRequestReasonMalformedJSON,
|
|
}
|
|
case errors.As(err, &typeErr):
|
|
if strings.TrimSpace(typeErr.Field) != "" {
|
|
return &malformedJSONRequestError{
|
|
message: fmt.Sprintf("request body contains an invalid value for %q", typeErr.Field),
|
|
reason: PublicMalformedRequestReasonInvalidJSONValue,
|
|
}
|
|
}
|
|
return &malformedJSONRequestError{
|
|
message: "request body contains an invalid JSON value",
|
|
reason: PublicMalformedRequestReasonInvalidJSONValue,
|
|
}
|
|
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
|
return &malformedJSONRequestError{
|
|
message: fmt.Sprintf("request body contains unknown field %s", strings.TrimPrefix(err.Error(), "json: unknown field ")),
|
|
reason: PublicMalformedRequestReasonUnknownField,
|
|
}
|
|
default:
|
|
return &malformedJSONRequestError{
|
|
message: "request body contains invalid JSON",
|
|
reason: PublicMalformedRequestReasonMalformedJSON,
|
|
}
|
|
}
|
|
}
|
|
|
|
func validateSendEmailCodeInput(input *SendEmailCodeInput) error {
|
|
input.Email = strings.TrimSpace(input.Email)
|
|
if input.Email == "" {
|
|
return errors.New("email must not be empty")
|
|
}
|
|
|
|
parsedAddress, err := mail.ParseAddress(input.Email)
|
|
if err != nil || parsedAddress.Name != "" || parsedAddress.Address != input.Email {
|
|
return errors.New("email must be a single valid email address")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateSendEmailCodeResult(result *SendEmailCodeResult) error {
|
|
result.ChallengeID = strings.TrimSpace(result.ChallengeID)
|
|
if result.ChallengeID == "" {
|
|
return errors.New("auth service returned an empty challenge_id")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateConfirmEmailCodeInput(input *ConfirmEmailCodeInput) error {
|
|
input.ChallengeID = strings.TrimSpace(input.ChallengeID)
|
|
if input.ChallengeID == "" {
|
|
return errors.New("challenge_id must not be empty")
|
|
}
|
|
|
|
input.Code = strings.TrimSpace(input.Code)
|
|
if input.Code == "" {
|
|
return errors.New("code must not be empty")
|
|
}
|
|
|
|
input.ClientPublicKey = strings.TrimSpace(input.ClientPublicKey)
|
|
if input.ClientPublicKey == "" {
|
|
return errors.New("client_public_key must not be empty")
|
|
}
|
|
|
|
input.TimeZone = strings.TrimSpace(input.TimeZone)
|
|
if input.TimeZone == "" {
|
|
return errors.New("time_zone must not be empty")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateConfirmEmailCodeResult(result *ConfirmEmailCodeResult) error {
|
|
result.DeviceSessionID = strings.TrimSpace(result.DeviceSessionID)
|
|
if result.DeviceSessionID == "" {
|
|
return errors.New("auth service returned an empty device_session_id")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func malformedRequestReasonFromError(err error) (PublicMalformedRequestReason, bool) {
|
|
var malformedErr *malformedJSONRequestError
|
|
if !errors.As(err, &malformedErr) {
|
|
return "", false
|
|
}
|
|
|
|
return malformedErr.reason, true
|
|
}
|
|
|
|
func extractPublicAuthIdentity(requestPath string, bodyBytes []byte) (publicAuthIdentity, error) {
|
|
switch requestPath {
|
|
case "/api/v1/public/auth/send-email-code":
|
|
var input SendEmailCodeInput
|
|
if err := decodeJSONBytes(bodyBytes, &input); err != nil {
|
|
return publicAuthIdentity{}, err
|
|
}
|
|
if err := validateSendEmailCodeInput(&input); err != nil {
|
|
return publicAuthIdentity{}, err
|
|
}
|
|
|
|
return publicAuthIdentity{
|
|
kind: "email",
|
|
value: input.Email,
|
|
}, nil
|
|
case "/api/v1/public/auth/confirm-email-code":
|
|
var input ConfirmEmailCodeInput
|
|
if err := decodeJSONBytes(bodyBytes, &input); err != nil {
|
|
return publicAuthIdentity{}, err
|
|
}
|
|
if err := validateConfirmEmailCodeInput(&input); err != nil {
|
|
return publicAuthIdentity{}, err
|
|
}
|
|
|
|
return publicAuthIdentity{
|
|
kind: "challenge",
|
|
value: input.ChallengeID,
|
|
}, nil
|
|
default:
|
|
return publicAuthIdentity{}, errPublicAuthIdentityNotApplicable
|
|
}
|
|
}
|