feat: edge gateway service
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user