wg-portal/internal/app/api/v0/handlers/endpoint_authentication.go
2025-03-09 21:48:38 +01:00

389 lines
12 KiB
Go

package handlers
import (
"context"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-pkgz/routegroup"
"github.com/h44z/wg-portal/internal/app/api/core/request"
"github.com/h44z/wg-portal/internal/app/api/core/respond"
"github.com/h44z/wg-portal/internal/app/api/v0/model"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
type AuthenticationService interface {
// GetExternalLoginProviders returns a list of all available external login providers.
GetExternalLoginProviders(_ context.Context) []domain.LoginProviderInfo
// PlainLogin authenticates a user with a username and password.
PlainLogin(ctx context.Context, username, password string) (*domain.User, error)
// OauthLoginStep1 initiates the OAuth login flow.
OauthLoginStep1(_ context.Context, providerId string) (authCodeUrl, state, nonce string, err error)
// OauthLoginStep2 completes the OAuth login flow and logins the user in.
OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error)
}
type AuthEndpoint struct {
cfg *config.Config
authService AuthenticationService
authenticator Authenticator
session Session
validate Validator
}
func NewAuthEndpoint(
cfg *config.Config,
authenticator Authenticator,
session Session,
validator Validator,
authService AuthenticationService,
) AuthEndpoint {
return AuthEndpoint{
cfg: cfg,
authService: authService,
authenticator: authenticator,
session: session,
validate: validator,
}
}
func (e AuthEndpoint) GetName() string {
return "AuthEndpoint"
}
func (e AuthEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup := g.Mount("/auth")
apiGroup.HandleFunc("GET /providers", e.handleExternalLoginProvidersGet())
apiGroup.HandleFunc("GET /session", e.handleSessionInfoGet())
apiGroup.HandleFunc("GET /login/{provider}/init", e.handleOauthInitiateGet())
apiGroup.HandleFunc("GET /login/{provider}/callback", e.handleOauthCallbackGet())
apiGroup.HandleFunc("POST /login", e.handleLoginPost())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /logout", e.handleLogoutPost())
}
// handleExternalLoginProvidersGet returns a gorm Handler function.
//
// @ID auth_handleExternalLoginProvidersGet
// @Tags Authentication
// @Summary Get all available external login providers.
// @Produce json
// @Success 200 {object} []model.LoginProviderInfo
// @Router /auth/providers [get]
func (e AuthEndpoint) handleExternalLoginProvidersGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
providers := e.authService.GetExternalLoginProviders(r.Context())
respond.JSON(w, http.StatusOK, model.NewLoginProviderInfos(providers))
}
}
// handleSessionInfoGet returns a gorm Handler function.
//
// @ID auth_handleSessionInfoGet
// @Tags Authentication
// @Summary Get information about the currently logged-in user.
// @Produce json
// @Success 200 {object} []model.SessionInfo
// @Failure 500 {object} model.Error
// @Router /auth/session [get]
func (e AuthEndpoint) handleSessionInfoGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
currentSession := e.session.GetData(r.Context())
var loggedInUid *string
var firstname *string
var lastname *string
var email *string
if currentSession.LoggedIn {
uid := currentSession.UserIdentifier
f := currentSession.Firstname
l := currentSession.Lastname
e := currentSession.Email
loggedInUid = &uid
firstname = &f
lastname = &l
email = &e
}
respond.JSON(w, http.StatusOK, model.SessionInfo{
LoggedIn: currentSession.LoggedIn,
IsAdmin: currentSession.IsAdmin,
UserIdentifier: loggedInUid,
UserFirstname: firstname,
UserLastname: lastname,
UserEmail: email,
})
}
}
// handleOauthInitiateGet returns a gorm Handler function.
//
// @ID auth_handleOauthInitiateGet
// @Tags Authentication
// @Summary Initiate the OAuth login flow.
// @Produce json
// @Success 200 {object} []model.LoginProviderInfo
// @Router /auth/{provider}/init [get]
func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
currentSession := e.session.GetData(r.Context())
autoRedirect, _ := strconv.ParseBool(request.QueryDefault(r, "redirect", "false"))
returnTo := request.Query(r, "return")
provider := request.Path(r, "provider")
var returnUrl *url.URL
var returnParams string
redirectToReturn := func() {
respond.Redirect(w, r, http.StatusFound, returnUrl.String()+"?"+returnParams)
}
if returnTo != "" {
if !e.isValidReturnUrl(returnTo) {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "invalid return URL"})
return
}
if u, err := url.Parse(returnTo); err == nil {
returnUrl = u
}
queryParams := returnUrl.Query()
queryParams.Set("wgLoginState", "err") // by default, we set the state to error
returnUrl.RawQuery = "" // remove potential query params
returnParams = queryParams.Encode()
}
if currentSession.LoggedIn {
if autoRedirect && e.isValidReturnUrl(returnTo) {
queryParams := returnUrl.Query()
queryParams.Set("wgLoginState", "success")
returnParams = queryParams.Encode()
redirectToReturn()
} else {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "already logged in"})
}
return
}
authCodeUrl, state, nonce, err := e.authService.OauthLoginStep1(context.Background(), provider)
if err != nil {
if autoRedirect && e.isValidReturnUrl(returnTo) {
redirectToReturn()
} else {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
}
return
}
authSession := e.session.GetData(r.Context())
authSession.OauthState = state
authSession.OauthNonce = nonce
authSession.OauthProvider = provider
authSession.OauthReturnTo = returnTo
e.session.SetData(r.Context(), authSession)
if autoRedirect {
respond.Redirect(w, r, http.StatusFound, authCodeUrl)
} else {
respond.JSON(w, http.StatusOK, model.OauthInitiationResponse{
RedirectUrl: authCodeUrl,
State: state,
})
}
}
}
// handleOauthCallbackGet returns a gorm Handler function.
//
// @ID auth_handleOauthCallbackGet
// @Tags Authentication
// @Summary Handle the OAuth callback.
// @Produce json
// @Success 200 {object} []model.LoginProviderInfo
// @Router /auth/{provider}/callback [get]
func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
currentSession := e.session.GetData(r.Context())
var returnUrl *url.URL
var returnParams string
redirectToReturn := func() {
respond.Redirect(w, r, http.StatusFound, returnUrl.String()+"?"+returnParams)
}
if currentSession.OauthReturnTo != "" {
if u, err := url.Parse(currentSession.OauthReturnTo); err == nil {
returnUrl = u
}
queryParams := returnUrl.Query()
queryParams.Set("wgLoginState", "err") // by default, we set the state to error
returnUrl.RawQuery = "" // remove potential query params
returnParams = queryParams.Encode()
}
if currentSession.LoggedIn {
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
queryParams := returnUrl.Query()
queryParams.Set("wgLoginState", "success")
returnParams = queryParams.Encode()
redirectToReturn()
} else {
respond.JSON(w, http.StatusBadRequest, model.Error{Message: "already logged in"})
}
return
}
provider := request.Path(r, "provider")
oauthCode := request.Query(r, "code")
oauthState := request.Query(r, "state")
if provider != currentSession.OauthProvider {
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
redirectToReturn()
} else {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "invalid oauth provider"})
}
return
}
if oauthState != currentSession.OauthState {
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
redirectToReturn()
} else {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "invalid oauth state"})
}
return
}
loginCtx, cancel := context.WithTimeout(context.Background(), 1000*time.Second)
user, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce,
oauthCode)
cancel()
if err != nil {
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
redirectToReturn()
} else {
respond.JSON(w, http.StatusUnauthorized,
model.Error{Code: http.StatusUnauthorized, Message: err.Error()})
}
return
}
e.setAuthenticatedUser(r, user)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
queryParams := returnUrl.Query()
queryParams.Set("wgLoginState", "success")
returnParams = queryParams.Encode()
redirectToReturn()
} else {
respond.JSON(w, http.StatusOK, user)
}
}
}
func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User) {
currentSession := e.session.GetData(r.Context())
currentSession.LoggedIn = true
currentSession.IsAdmin = user.IsAdmin
currentSession.UserIdentifier = string(user.Identifier)
currentSession.Firstname = user.Firstname
currentSession.Lastname = user.Lastname
currentSession.Email = user.Email
currentSession.OauthState = ""
currentSession.OauthNonce = ""
currentSession.OauthProvider = ""
currentSession.OauthReturnTo = ""
e.session.SetData(r.Context(), currentSession)
}
// handleLoginPost returns a gorm Handler function.
//
// @ID auth_handleLoginPost
// @Tags Authentication
// @Summary Get all available external login providers.
// @Produce json
// @Success 200 {object} []model.LoginProviderInfo
// @Router /auth/login [post]
func (e AuthEndpoint) handleLoginPost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
currentSession := e.session.GetData(r.Context())
if currentSession.LoggedIn {
respond.JSON(w, http.StatusOK, model.Error{Code: http.StatusOK, Message: "already logged in"})
return
}
var loginData struct {
Username string `json:"username" binding:"required,min=2"`
Password string `json:"password" binding:"required,min=4"`
}
if err := request.BodyJson(r, &loginData); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
if err := e.validate.Struct(loginData); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
user, err := e.authService.PlainLogin(context.Background(), loginData.Username,
loginData.Password)
if err != nil {
respond.JSON(w, http.StatusUnauthorized,
model.Error{Code: http.StatusUnauthorized, Message: "login failed"})
return
}
e.setAuthenticatedUser(r, user)
respond.JSON(w, http.StatusOK, user)
}
}
// handleLogoutPost returns a gorm Handler function.
//
// @ID auth_handleLogoutGet
// @Tags Authentication
// @Summary Get all available external login providers.
// @Produce json
// @Success 200 {object} []model.LoginProviderInfo
// @Router /auth/logout [get]
func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
currentSession := e.session.GetData(r.Context())
if !currentSession.LoggedIn { // Not logged in
respond.JSON(w, http.StatusOK, model.Error{Code: http.StatusOK, Message: "not logged in"})
return
}
e.session.DestroyData(r.Context())
respond.JSON(w, http.StatusOK, model.Error{Code: http.StatusOK, Message: "logout ok"})
}
}
// isValidReturnUrl checks if the given return URL matches the configured external URL of the application.
func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool {
if !strings.HasPrefix(returnUrl, e.cfg.Web.ExternalUrl) {
return false
}
return true
}