347 lines
8.2 KiB
Go
347 lines
8.2 KiB
Go
package jwt
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/datarhei/core/v16/app"
|
|
"github.com/datarhei/core/v16/http/api"
|
|
"github.com/datarhei/core/v16/http/handler/util"
|
|
"github.com/datarhei/core/v16/iam"
|
|
|
|
jwtgo "github.com/golang-jwt/jwt/v4"
|
|
"github.com/google/uuid"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/labstack/echo/v4/middleware"
|
|
)
|
|
|
|
// The Config type holds information that is required to create a new JWT provider
|
|
type Config struct {
|
|
Realm string
|
|
Secret string
|
|
SkipLocalhost bool
|
|
IAM iam.IAM
|
|
}
|
|
|
|
// JWT provides access to a JWT provider
|
|
type JWT interface {
|
|
// Middleware returns an echo middleware
|
|
Middleware() echo.MiddlewareFunc
|
|
|
|
// LoginHandler is an echo route handler for retrieving a JWT
|
|
LoginHandler(c echo.Context) error
|
|
|
|
// RefreshHandle is an echo route handler for refreshing a JWT
|
|
RefreshHandler(c echo.Context) error
|
|
}
|
|
|
|
type jwt struct {
|
|
realm string
|
|
skipLocalhost bool
|
|
secret []byte
|
|
accessValidFor time.Duration
|
|
refreshValidFor time.Duration
|
|
config middleware.JWTConfig
|
|
middleware echo.MiddlewareFunc
|
|
iam iam.IAM
|
|
}
|
|
|
|
// New returns a new JWT provider
|
|
func New(config Config) (JWT, error) {
|
|
j := &jwt{
|
|
realm: config.Realm,
|
|
skipLocalhost: config.SkipLocalhost,
|
|
secret: []byte(config.Secret),
|
|
accessValidFor: time.Minute * 10,
|
|
refreshValidFor: time.Hour * 24,
|
|
}
|
|
|
|
if len(j.secret) == 0 {
|
|
return nil, fmt.Errorf("the JWT secret must not be empty")
|
|
}
|
|
|
|
skipperFunc := func(c echo.Context) bool {
|
|
if j.skipLocalhost {
|
|
ip := c.RealIP()
|
|
|
|
if ip == "127.0.0.1" || ip == "::1" {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
j.config = middleware.JWTConfig{
|
|
Skipper: skipperFunc,
|
|
SigningMethod: middleware.AlgorithmHS256,
|
|
ContextKey: "user",
|
|
TokenLookup: "header:" + echo.HeaderAuthorization,
|
|
AuthScheme: "Bearer",
|
|
Claims: jwtgo.MapClaims{},
|
|
ErrorHandlerWithContext: j.ErrorHandler,
|
|
SuccessHandler: func(c echo.Context) {
|
|
token := c.Get("user").(*jwtgo.Token)
|
|
|
|
var subject string
|
|
if claims, ok := token.Claims.(jwtgo.MapClaims); ok {
|
|
if sub, ok := claims["sub"]; ok {
|
|
subject = sub.(string)
|
|
}
|
|
}
|
|
|
|
c.Set("user", subject)
|
|
|
|
var usefor string
|
|
if claims, ok := token.Claims.(jwtgo.MapClaims); ok {
|
|
if sub, ok := claims["usefor"]; ok {
|
|
usefor = sub.(string)
|
|
}
|
|
}
|
|
|
|
c.Set("usefor", usefor)
|
|
},
|
|
ParseTokenFunc: j.parseToken,
|
|
}
|
|
|
|
return j, nil
|
|
}
|
|
|
|
func (j *jwt) parseToken(auth string, c echo.Context) (interface{}, error) {
|
|
keyFunc := func(*jwtgo.Token) (interface{}, error) { return j.secret, nil }
|
|
|
|
var token *jwtgo.Token
|
|
var err error
|
|
|
|
token, err = jwtgo.Parse(auth, keyFunc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !token.Valid {
|
|
return nil, errors.New("invalid token")
|
|
}
|
|
|
|
if _, ok := token.Claims.(jwtgo.MapClaims)["sub"]; !ok {
|
|
return nil, fmt.Errorf("sub claim is required")
|
|
}
|
|
|
|
if _, ok := token.Claims.(jwtgo.MapClaims)["usefor"]; !ok {
|
|
return nil, fmt.Errorf("usefor claim is required")
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
func (j *jwt) ErrorHandler(err error, c echo.Context) error {
|
|
if c.Request().URL.Path == "/api" {
|
|
return c.JSON(http.StatusOK, api.MinimalAbout{
|
|
App: app.Name,
|
|
Auths: []string{},
|
|
Version: api.VersionMinimal{
|
|
Number: app.Version.MajorString(),
|
|
},
|
|
})
|
|
}
|
|
|
|
return api.Err(http.StatusUnauthorized, "Missing or invalid JWT token")
|
|
}
|
|
|
|
func (j *jwt) Middleware() echo.MiddlewareFunc {
|
|
if j.middleware == nil {
|
|
j.middleware = middleware.JWTWithConfig(j.config)
|
|
}
|
|
|
|
return j.middleware
|
|
}
|
|
|
|
// LoginHandler returns an access token and a refresh token
|
|
// @Summary Retrieve an access and a refresh token
|
|
// @Description Retrieve valid JWT access and refresh tokens to use for accessing the API. Login either by username/password or Auth0 token
|
|
// @ID jwt-login
|
|
// @Produce json
|
|
// @Param data body api.Login true "Login data"
|
|
// @Success 200 {object} api.JWT
|
|
// @Failure 400 {object} api.Error
|
|
// @Failure 403 {object} api.Error
|
|
// @Failure 500 {object} api.Error
|
|
// @Security Auth0KeyAuth
|
|
// @Router /api/login [post]
|
|
func (j *jwt) LoginHandler(c echo.Context) error {
|
|
ok, subject, err := j.validateLogin(c)
|
|
|
|
if ok {
|
|
if err != nil {
|
|
time.Sleep(5 * time.Second)
|
|
return api.Err(http.StatusUnauthorized, "Invalid authorization credentials", "%s", err)
|
|
}
|
|
} else {
|
|
time.Sleep(5 * time.Second)
|
|
return api.Err(http.StatusBadRequest, "Missing authorization credentials")
|
|
}
|
|
|
|
at, rt, err := j.createToken(subject)
|
|
if err != nil {
|
|
return api.Err(http.StatusInternalServerError, "Failed to create JWT", "%s", err)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, api.JWT{
|
|
AccessToken: at,
|
|
RefreshToken: rt,
|
|
})
|
|
}
|
|
|
|
func (j *jwt) validateLogin(c echo.Context) (bool, string, error) {
|
|
ok, subject, err := j.validateUserpassLogin(c)
|
|
if ok {
|
|
return ok, subject, err
|
|
}
|
|
|
|
return j.validateAuth0Login(c)
|
|
}
|
|
|
|
func (j *jwt) validateUserpassLogin(c echo.Context) (bool, string, error) {
|
|
var login api.Login
|
|
|
|
if err := util.ShouldBindJSON(c, &login); err != nil {
|
|
return false, "", nil
|
|
}
|
|
|
|
identity, err := j.iam.GetIdentity(login.Username)
|
|
if err != nil {
|
|
return true, "", fmt.Errorf("invalid username or password")
|
|
}
|
|
|
|
if !identity.VerifyAPIPassword(login.Password) {
|
|
return true, "", fmt.Errorf("invalid username or password")
|
|
}
|
|
|
|
return true, identity.Name(), nil
|
|
}
|
|
|
|
func (j *jwt) validateAuth0Login(c echo.Context) (bool, string, error) {
|
|
// Look for an Auth header
|
|
values := c.Request().Header.Values("Authorization")
|
|
prefix := "Bearer "
|
|
|
|
auth := ""
|
|
for _, value := range values {
|
|
if !strings.HasPrefix(value, prefix) {
|
|
continue
|
|
}
|
|
|
|
auth = value[len(prefix):]
|
|
|
|
break
|
|
}
|
|
|
|
if len(auth) == 0 {
|
|
return false, "", nil
|
|
}
|
|
|
|
p := &jwtgo.Parser{}
|
|
token, _, err := p.ParseUnverified(auth, jwtgo.MapClaims{})
|
|
if err != nil {
|
|
return false, "", nil
|
|
}
|
|
|
|
var subject string
|
|
if claims, ok := token.Claims.(jwtgo.MapClaims); ok {
|
|
if sub, ok := claims["sub"]; ok {
|
|
subject = sub.(string)
|
|
}
|
|
}
|
|
|
|
identity, err := j.iam.GetIdentityByAuth0(subject)
|
|
if err != nil {
|
|
return true, "", fmt.Errorf("invalid token")
|
|
}
|
|
|
|
if !identity.VerifyAPIAuth0(auth) {
|
|
return true, "", fmt.Errorf("invalid token")
|
|
}
|
|
|
|
return true, identity.Name(), nil
|
|
}
|
|
|
|
// RefreshHandler returns a new refresh token
|
|
// @Summary Retrieve a new access token
|
|
// @Description Retrieve a new access token by providing the refresh token
|
|
// @ID jwt-refresh
|
|
// @Produce json
|
|
// @Success 200 {object} api.JWTRefresh
|
|
// @Failure 500 {object} api.Error
|
|
// @Security ApiRefreshKeyAuth
|
|
// @Router /api/login/refresh [get]
|
|
func (j *jwt) RefreshHandler(c echo.Context) error {
|
|
subject, ok := c.Get("user").(string)
|
|
if !ok {
|
|
return api.Err(http.StatusForbidden, "Invalid token")
|
|
}
|
|
|
|
usefor, ok := c.Get("usefor").(string)
|
|
if !ok {
|
|
return api.Err(http.StatusForbidden, "Invalid token")
|
|
}
|
|
|
|
if usefor != "refresh" {
|
|
return api.Err(http.StatusForbidden, "Invalid token")
|
|
}
|
|
|
|
at, _, err := j.createToken(subject)
|
|
if err != nil {
|
|
return api.Err(http.StatusInternalServerError, "Failed to create JWT", "%s", err)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, api.JWTRefresh{
|
|
AccessToken: at,
|
|
})
|
|
}
|
|
|
|
// Already assigned claims: https://www.iana.org/assignments/jwt/jwt.xhtml
|
|
|
|
func (j *jwt) createToken(username string) (string, string, error) {
|
|
now := time.Now()
|
|
accessExpires := now.Add(j.accessValidFor)
|
|
refreshExpires := now.Add(j.refreshValidFor)
|
|
|
|
// Create access token
|
|
accessToken := jwtgo.NewWithClaims(jwtgo.SigningMethodHS256, jwtgo.MapClaims{
|
|
"iss": j.realm,
|
|
"sub": username,
|
|
"usefor": "access",
|
|
"iat": now.Unix(),
|
|
"exp": accessExpires.Unix(),
|
|
"exi": uint64(accessExpires.Sub(now).Seconds()),
|
|
"jti": uuid.New().String(),
|
|
})
|
|
|
|
// Generate encoded access token
|
|
at, err := accessToken.SignedString(j.secret)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
// Create refresh token
|
|
refreshToken := jwtgo.NewWithClaims(jwtgo.SigningMethodHS256, jwtgo.MapClaims{
|
|
"iss": j.realm,
|
|
"sub": username,
|
|
"usefor": "refresh",
|
|
"iat": now.Unix(),
|
|
"exp": refreshExpires.Unix(),
|
|
"exi": uint64(refreshExpires.Sub(now).Seconds()),
|
|
"jti": uuid.New().String(),
|
|
})
|
|
|
|
// Generate encoded refresh token
|
|
rt, err := refreshToken.SignedString(j.secret)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
return at, rt, nil
|
|
}
|