From 39bc4adef699a55d8f014568c8d3745bbd3358bc Mon Sep 17 00:00:00 2001 From: Yang Hau Date: Mon, 24 Jul 2023 17:46:29 +0300 Subject: [PATCH 1/2] refactor: Refactor authentication lib --- packages/authentication/auth_context.go | 19 ++ packages/authentication/basic_auth.go | 35 ---- packages/authentication/context.go | 44 ----- packages/authentication/ip_whitelist.go | 53 ------ packages/authentication/jwt_auth.go | 146 +++------------ packages/authentication/jwt_auth_test.go | 17 +- packages/authentication/jwt_handler.go | 106 ----------- packages/authentication/jwt_login.go | 67 +++++++ packages/authentication/routes.go | 108 +++++++++++ packages/authentication/shared/routes.go | 4 - packages/authentication/status.go | 33 ---- packages/authentication/strategy.go | 171 ------------------ .../authentication/validate_middleware.go | 114 ++++++++++++ .../authentication/validate_permissions.go | 4 - packages/webapi/api.go | 12 +- .../webapi/models/mock/AuthInfoModel.json | 4 + packages/webapi/models/mock/LoginRequest.json | 4 + .../webapi/models/mock/LoginResponse.json | 3 + tools/cluster/templates/waspconfig.go | 8 - 19 files changed, 355 insertions(+), 597 deletions(-) create mode 100644 packages/authentication/auth_context.go delete mode 100644 packages/authentication/basic_auth.go delete mode 100644 packages/authentication/context.go delete mode 100644 packages/authentication/ip_whitelist.go delete mode 100644 packages/authentication/jwt_handler.go create mode 100644 packages/authentication/jwt_login.go create mode 100644 packages/authentication/routes.go delete mode 100644 packages/authentication/status.go delete mode 100644 packages/authentication/strategy.go create mode 100644 packages/authentication/validate_middleware.go create mode 100644 packages/webapi/models/mock/AuthInfoModel.json create mode 100644 packages/webapi/models/mock/LoginRequest.json create mode 100644 packages/webapi/models/mock/LoginResponse.json diff --git a/packages/authentication/auth_context.go b/packages/authentication/auth_context.go new file mode 100644 index 0000000000..679a42d34c --- /dev/null +++ b/packages/authentication/auth_context.go @@ -0,0 +1,19 @@ +package authentication + +import "github.com/labstack/echo/v4" + +type AuthContext struct { + echo.Context + + scheme string + claims *WaspClaims + name string +} + +func (a *AuthContext) Name() string { + return a.name +} + +func (a *AuthContext) Scheme() string { + return a.scheme +} diff --git a/packages/authentication/basic_auth.go b/packages/authentication/basic_auth.go deleted file mode 100644 index 12338e6bd5..0000000000 --- a/packages/authentication/basic_auth.go +++ /dev/null @@ -1,35 +0,0 @@ -package authentication - -import ( - "fmt" - - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - - "github.com/iotaledger/hive.go/web/basicauth" - "github.com/iotaledger/wasp/packages/users" -) - -func AddBasicAuth(webAPI WebAPI, userManager *users.UserManager) { - webAPI.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) { - authContext := c.Get("auth").(*AuthContext) - - user, err := userManager.User(username) - if err != nil { - return false, err - } - - valid, err := basicauth.VerifyPassword([]byte(password), user.PasswordSalt, user.PasswordHash) - if err != nil { - return false, fmt.Errorf("failed to verify password: %w", err) - } - - if !valid { - return false, nil - } - - authContext.name = username - authContext.isAuthenticated = true - return true, nil - })) -} diff --git a/packages/authentication/context.go b/packages/authentication/context.go deleted file mode 100644 index 62496211cc..0000000000 --- a/packages/authentication/context.go +++ /dev/null @@ -1,44 +0,0 @@ -package authentication - -import ( - "github.com/labstack/echo/v4" -) - -type ( - ClaimValidator func(claims *WaspClaims) bool - AccessValidator func(validator ClaimValidator) bool -) - -type AuthContext struct { - echo.Context - - scheme string - isAuthenticated bool - claims *WaspClaims - name string -} - -func (a *AuthContext) Name() string { - return a.name -} - -func (a *AuthContext) IsAuthenticated() bool { - return a.isAuthenticated -} - -func (a *AuthContext) Scheme() string { - return a.scheme -} - -func (a *AuthContext) IsAllowedTo(validator ClaimValidator) bool { - if !a.isAuthenticated { - return false - } - - if a.scheme == AuthJWT { - return validator(a.claims) - } - - // IP Whitelist and Basic Auth will always give access to everything! - return true -} diff --git a/packages/authentication/ip_whitelist.go b/packages/authentication/ip_whitelist.go deleted file mode 100644 index e58944d44e..0000000000 --- a/packages/authentication/ip_whitelist.go +++ /dev/null @@ -1,53 +0,0 @@ -package authentication - -import ( - "net" - "strings" - - "github.com/labstack/echo/v4" -) - -func AddIPWhiteListAuth(webAPI WebAPI, config IPWhiteListAuthConfiguration) { - ipWhiteList := createIPWhiteList(config) - webAPI.Use(protected(ipWhiteList)) -} - -func createIPWhiteList(config IPWhiteListAuthConfiguration) []net.IP { - r := make([]net.IP, 0) - for _, ip := range config.Whitelist { - r = append(r, net.ParseIP(ip)) - } - return r -} - -func isAllowed(ip net.IP, whitelist []net.IP) bool { - if ip.IsLoopback() { - return true - } - for _, whitelistedIP := range whitelist { - if ip.Equal(whitelistedIP) { - return true - } - } - return false -} - -func protected(whitelist []net.IP) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - authContext := c.Get("auth").(*AuthContext) - - parts := strings.Split(c.Request().RemoteAddr, ":") - if len(parts) == 2 { - ip := net.ParseIP(parts[0]) - if ip != nil && isAllowed(ip, whitelist) { - authContext.isAuthenticated = true - return next(c) - } - } - - c.Logger().Infof("Blocking request from %s: %s %s", c.Request().RemoteAddr, c.Request().Method, c.Request().RequestURI) - return echo.ErrUnauthorized - } - } -} diff --git a/packages/authentication/jwt_auth.go b/packages/authentication/jwt_auth.go index c280125ca6..4f02baa94b 100644 --- a/packages/authentication/jwt_auth.go +++ b/packages/authentication/jwt_auth.go @@ -4,16 +4,12 @@ import ( "crypto/subtle" "fmt" "net/http" - "strings" "time" "github.com/golang-jwt/jwt/v5" - echojwt "github.com/labstack/echo-jwt/v4" "github.com/labstack/echo/v4" - "github.com/iotaledger/wasp/packages/authentication/shared" "github.com/iotaledger/wasp/packages/authentication/shared/permissions" - "github.com/iotaledger/wasp/packages/users" ) // Errors @@ -32,8 +28,6 @@ type JWTAuth struct { secret []byte } -type MiddlewareValidator = func(c echo.Context, authContext *AuthContext) bool - func NewJWTAuth(duration time.Duration, nodeID string, secret []byte) *JWTAuth { return &JWTAuth{ duration: duration, @@ -42,6 +36,32 @@ func NewJWTAuth(duration time.Duration, nodeID string, secret []byte) *JWTAuth { } } +func (j *JWTAuth) IssueJWT(username string, claims *WaspClaims) (string, error) { + now := time.Now() + + // Set claims + registeredClaims := jwt.RegisteredClaims{ + Subject: username, + Issuer: j.nodeID, + Audience: jwt.ClaimStrings{j.nodeID}, + ID: fmt.Sprintf("%d", now.Unix()), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + } + + if j.duration > 0 { + registeredClaims.ExpiresAt = jwt.NewNumericDate(now.Add(j.duration)) + } + + claims.RegisteredClaims = registeredClaims + + // Create token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + // Generate encoded token and send it as response. + return token.SignedString(j.secret) +} + type WaspClaims struct { jwt.RegisteredClaims Permissions map[string]struct{} `json:"permissions"` @@ -78,117 +98,3 @@ func (c *WaspClaims) compare(field, expected string) bool { func (c *WaspClaims) VerifySubject(expected string) bool { return c.compare(c.Subject, expected) } - -func (j *JWTAuth) IssueJWT(username string, authClaims *WaspClaims) (string, error) { - now := time.Now() - - // Set claims - registeredClaims := jwt.RegisteredClaims{ - Subject: username, - Issuer: j.nodeID, - Audience: jwt.ClaimStrings{j.nodeID}, - ID: fmt.Sprintf("%d", now.Unix()), - IssuedAt: jwt.NewNumericDate(now), - NotBefore: jwt.NewNumericDate(now), - } - - if j.duration > 0 { - registeredClaims.ExpiresAt = jwt.NewNumericDate(now.Add(j.duration)) - } - - authClaims.RegisteredClaims = registeredClaims - - // Create token - token := jwt.NewWithClaims(jwt.SigningMethodHS256, authClaims) - - // Generate encoded token and send it as response. - return token.SignedString(j.secret) -} - -var DefaultJWTDuration time.Duration - -func AddJWTAuth(config JWTAuthConfiguration, privateKey []byte, userManager *users.UserManager, claimValidator ClaimValidator) (*JWTAuth, func() echo.MiddlewareFunc) { - duration := config.Duration - - // If durationHours is 0, we set 24h as the default duration - if duration == 0 { - duration = DefaultJWTDuration - } - - // FIXME: replace "wasp" as nodeID - jwtAuth := NewJWTAuth(duration, "wasp", privateKey) - - authMiddleware := func() echo.MiddlewareFunc { - return echojwt.WithConfig(echojwt.Config{ - ContextKey: JWTContextKey, - NewClaimsFunc: func(c echo.Context) jwt.Claims { - return &WaspClaims{} - }, - Skipper: func(c echo.Context) bool { - path := c.Request().URL.Path - if path == "/" || - strings.HasSuffix(path, shared.AuthRoute()) || - strings.HasSuffix(path, shared.AuthInfoRoute()) || - strings.HasPrefix(path, "/doc") { - return true - } - - return false - }, - SigningKey: jwtAuth.secret, - TokenLookup: "header:Authorization:Bearer ,cookie:jwt", - ParseTokenFunc: func(c echo.Context, auth string) (interface{}, error) { - keyFunc := func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) - } - - return jwtAuth.secret, nil - } - - token, err := jwt.ParseWithClaims( - auth, - &WaspClaims{}, - keyFunc, - jwt.WithValidMethods([]string{"HS256"}), - ) - if err != nil { - return nil, err - } - if !token.Valid { - return nil, fmt.Errorf("invalid token") - } - - claims, ok := token.Claims.(*WaspClaims) - if !ok { - return nil, fmt.Errorf("wrong JWT claim type") - } - - audience, err := claims.GetAudience() - if err != nil { - return nil, err - } - b, err := audience.MarshalJSON() - if err != nil { - return nil, err - } - if subtle.ConstantTimeCompare(b, []byte(fmt.Sprintf("[%q]", jwtAuth.nodeID))) == 0 { - return nil, fmt.Errorf("not in audience") - } - - userMap := userManager.Users() - if _, ok := userMap[claims.Subject]; !ok { - return nil, fmt.Errorf("invalid subject") - } - - authContext := c.Get("auth").(*AuthContext) - authContext.isAuthenticated = true - authContext.claims = claims - - return token, nil - }, - }) - } - - return jwtAuth, authMiddleware -} diff --git a/packages/authentication/jwt_auth_test.go b/packages/authentication/jwt_auth_test.go index 35e6070742..80b3d22614 100644 --- a/packages/authentication/jwt_auth_test.go +++ b/packages/authentication/jwt_auth_test.go @@ -16,7 +16,7 @@ import ( "github.com/iotaledger/wasp/packages/users" ) -func TestAddJWTAuth(t *testing.T) { +func TestGetJWTAuthMiddleware(t *testing.T) { t.Run("normal", func(t *testing.T) { e := echo.New() e.GET("/test-route", func(c echo.Context) error { @@ -32,11 +32,10 @@ func TestAddJWTAuth(t *testing.T) { Name: "wasp", }) - _, middleware := authentication.AddJWTAuth( + _, middleware := authentication.GetJWTAuthMiddleware( authentication.JWTAuthConfiguration{}, []byte("abc"), userManager, - nil, // remove claim validator ) e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { @@ -45,7 +44,7 @@ func TestAddJWTAuth(t *testing.T) { return next(c) } }) - e.Use(middleware()) + e.Use(middleware) req := httptest.NewRequest(http.MethodGet, "/test-route", http.NoBody) req.Header.Set(echo.HeaderAuthorization, "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ3YXNwIiwic3ViIjoid2FzcCIsImF1ZCI6WyJ3YXNwIl0sImV4cCI6NDg0NTUwNjQ5MiwibmJmIjoxNjg5ODYxNDM2LCJpYXQiOjE2ODk4NjE0MzYsImp0aSI6IjE2ODk4NjE0MzYiLCJwZXJtaXNzaW9ucyI6eyJ3cml0ZSI6e319fQ.VP--725H3xO2Spz6L9twB6Tsm37a26IXVU87cSqRoOM") @@ -73,13 +72,12 @@ func TestAddJWTAuth(t *testing.T) { }) } - _, middleware := authentication.AddJWTAuth( + _, middleware := authentication.GetJWTAuthMiddleware( authentication.JWTAuthConfiguration{}, []byte(""), &users.UserManager{}, - nil, // remove claim validator ) - e.Use(middleware()) + e.Use(middleware) for _, path := range skipPaths { req := httptest.NewRequest(http.MethodGet, path, http.NoBody) @@ -119,11 +117,10 @@ func TestJWTAuthIssueAndVerify(t *testing.T) { Name: username, }) - _, middleware := authentication.AddJWTAuth( + _, middleware := authentication.GetJWTAuthMiddleware( authentication.JWTAuthConfiguration{Duration: duration}, privateKey, userManager, - nil, // remove claim validator ) e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { @@ -132,7 +129,7 @@ func TestJWTAuthIssueAndVerify(t *testing.T) { return next(c) } }) - e.Use(middleware()) + e.Use(middleware) req := httptest.NewRequest(http.MethodGet, "/test-route", http.NoBody) req.Header.Set(echo.HeaderAuthorization, fmt.Sprintf("Bearer %s", jwtString)) diff --git a/packages/authentication/jwt_handler.go b/packages/authentication/jwt_handler.go deleted file mode 100644 index cb7618f2c0..0000000000 --- a/packages/authentication/jwt_handler.go +++ /dev/null @@ -1,106 +0,0 @@ -package authentication - -import ( - "errors" - "fmt" - "net/http" - "time" - - "github.com/labstack/echo/v4" - - "github.com/iotaledger/hive.go/web/basicauth" - "github.com/iotaledger/wasp/packages/authentication/shared" - "github.com/iotaledger/wasp/packages/users" -) - -const headerXForwardedPrefix = "X-Forwarded-Prefix" - -type AuthHandler struct { - Jwt *JWTAuth - UserManager *users.UserManager -} - -func (a *AuthHandler) validateLogin(user *users.User, password string) bool { - valid, err := basicauth.VerifyPassword([]byte(password), user.PasswordSalt, user.PasswordHash) - if err != nil { - return false - } - - return valid -} - -func (a *AuthHandler) stageAuthRequest(c echo.Context) (string, error) { - request := &shared.LoginRequest{} - - if err := c.Bind(request); err != nil { - return "", errors.New("invalid form data") - } - - user, err := a.UserManager.User(request.Username) - if err != nil { - return "", errors.New("invalid credentials") - } - - if !a.validateLogin(user, request.Password) { - return "", errors.New("invalid credentials") - } - - claims := &WaspClaims{ - Permissions: user.Permissions, - } - - token, err := a.Jwt.IssueJWT(request.Username, claims) - if err != nil { - return "", errors.New("unable to login") - } - - return token, nil -} - -func (a *AuthHandler) handleJSONAuthRequest(c echo.Context, token string, errorResult error) error { - if errorResult != nil { - return c.JSON(http.StatusUnauthorized, shared.LoginResponse{Error: errorResult}) - } - - return c.JSON(http.StatusOK, shared.LoginResponse{JWT: token}) -} - -func (a *AuthHandler) redirect(c echo.Context, uri string) error { - return c.Redirect(http.StatusFound, c.Request().Header.Get(headerXForwardedPrefix)+uri) -} - -func (a *AuthHandler) handleFormAuthRequest(c echo.Context, token string, errorResult error) error { - if errorResult != nil { - // TODO: Add sessions to get rid of the query parameter? - return a.redirect(c, fmt.Sprintf("%s?error=%s", shared.AuthRoute(), errorResult)) - } - - cookie := http.Cookie{ - Name: "jwt", - Value: token, - HttpOnly: true, // JWT Token will be stored in a http only cookie, this is important to mitigate XSS/XSRF attacks - Expires: time.Now().Add(a.Jwt.duration), - Path: "/", - SameSite: http.SameSiteStrictMode, - } - - c.SetCookie(&cookie) - - return a.redirect(c, shared.AuthRouteSuccess()) -} - -func (a *AuthHandler) CrossAPIAuthHandler(c echo.Context) error { - token, errorResult := a.stageAuthRequest(c) - - contentType := c.Request().Header.Get(echo.HeaderContentType) - - if contentType == echo.MIMEApplicationJSON { - return a.handleJSONAuthRequest(c, token, errorResult) - } - - if contentType == echo.MIMEApplicationForm { - return a.handleFormAuthRequest(c, token, errorResult) - } - - return errors.New("invalid login request") -} diff --git a/packages/authentication/jwt_login.go b/packages/authentication/jwt_login.go new file mode 100644 index 0000000000..700de01cf7 --- /dev/null +++ b/packages/authentication/jwt_login.go @@ -0,0 +1,67 @@ +package authentication + +import ( + "errors" + "fmt" + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/iotaledger/hive.go/web/basicauth" + "github.com/iotaledger/wasp/packages/authentication/shared" + "github.com/iotaledger/wasp/packages/users" +) + +type AuthHandler struct { + Jwt *JWTAuth + UserManager *users.UserManager +} + +func (a *AuthHandler) JWTLoginHandler(c echo.Context) error { + if c.Request().Header.Get(echo.HeaderContentType) != echo.MIMEApplicationJSON { + return errors.New("invalid login request") + } + + req, user, err := a.parseAuthRequest(c) + if err != nil { + return c.JSON(http.StatusUnauthorized, shared.LoginResponse{Error: err}) + } + + claims := &WaspClaims{ + Permissions: user.Permissions, + } + token, err := a.Jwt.IssueJWT(req.Username, claims) + if err != nil { + return c.JSON(http.StatusUnauthorized, shared.LoginResponse{Error: fmt.Errorf("unable to login")}) + } + + return c.JSON(http.StatusOK, shared.LoginResponse{JWT: token}) +} + +func (a *AuthHandler) parseAuthRequest(c echo.Context) (*shared.LoginRequest, *users.User, error) { + request := &shared.LoginRequest{} + + if err := c.Bind(request); err != nil { + return nil, nil, fmt.Errorf("invalid form data") + } + + user, err := a.UserManager.User(request.Username) + if err != nil { + return nil, nil, fmt.Errorf("invalid credentials") + } + + if !validatePassword(user, request.Password) { + return nil, nil, fmt.Errorf("invalid credentials") + } + + return request, user, nil +} + +func validatePassword(user *users.User, password string) bool { + valid, err := basicauth.VerifyPassword([]byte(password), user.PasswordSalt, user.PasswordHash) + if err != nil { + return false + } + + return valid +} diff --git a/packages/authentication/routes.go b/packages/authentication/routes.go new file mode 100644 index 0000000000..d509f340e2 --- /dev/null +++ b/packages/authentication/routes.go @@ -0,0 +1,108 @@ +package authentication + +import ( + "fmt" + "net/http" + "time" + + "github.com/labstack/echo/v4" + "github.com/pangpanglabs/echoswagger/v2" + + "github.com/iotaledger/wasp/packages/authentication/shared" + "github.com/iotaledger/wasp/packages/registry" + "github.com/iotaledger/wasp/packages/users" + "github.com/iotaledger/wasp/packages/webapi/interfaces" +) + +const ( + AuthNone = "none" + AuthJWT = "jwt" +) + +type JWTAuthConfiguration struct { + Duration time.Duration `default:"24h" usage:"jwt token lifetime"` +} + +type AuthConfiguration struct { + Scheme string `default:"ip" usage:"selects which authentication to choose"` + + JWTConfig JWTAuthConfiguration `name:"jwt" usage:"defines the jwt configuration"` +} + +type WebAPI interface { + GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + Use(middleware ...echo.MiddlewareFunc) +} + +func AddAuthentication( + apiRoot echoswagger.ApiRoot, + userManager *users.UserManager, + nodeIdentityProvider registry.NodeIdentityProvider, + authConfig AuthConfiguration, + mocker interfaces.Mocker, +) echo.MiddlewareFunc { + echoRoot := apiRoot.Echo() + authGroup := apiRoot.Group("auth", "") + + // initialize AuthContext obj as var in echo.Context + echoRoot.Use(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + c.Set("auth", &AuthContext{ + scheme: authConfig.Scheme, + }) + + return next(c) + } + }) + + // set AuthInfo route + authGroup.GET(shared.AuthInfoRoute(), authInfoHandler(authConfig)). + AddResponse(http.StatusOK, "Login was successful", mocker.Get(shared.AuthInfoModel{}), nil). + SetOperationId("authInfo"). + SetSummary("Get information about the current authentication mode") + + // set Auth route + var middleware echo.MiddlewareFunc + var handler echo.HandlerFunc + switch authConfig.Scheme { + case AuthJWT: + var jwtAuth *JWTAuth + privateKey := nodeIdentityProvider.NodeIdentity().GetPrivateKey().AsBytes() + + // The primary claim is the one mandatory claim that gives access to api/webapi/alike + jwtAuth, middleware = GetJWTAuthMiddleware(authConfig.JWTConfig, privateKey, userManager) + authHandler := &AuthHandler{Jwt: jwtAuth, UserManager: userManager} + handler = authHandler.JWTLoginHandler + + case AuthNone: + middleware = GetNoneAuthMiddleware() + handler = nil + + default: + panic(fmt.Sprintf("Unknown auth scheme %s", authConfig.Scheme)) + } + + authGroup.POST(shared.AuthRoute(), handler). + AddParamBody(mocker.Get(shared.LoginRequest{}), "", "The login request", true). + AddResponse(http.StatusUnauthorized, "Unauthorized (Wrong permissions, missing token)", nil, nil). + AddResponse(http.StatusMethodNotAllowed, "auth type: none", nil, nil). + AddResponse(http.StatusOK, "Login was successful", mocker.Get(shared.LoginResponse{}), nil). + SetOperationId("authenticate"). + SetSummary("Authenticate towards the node") + return middleware +} + +func authInfoHandler(authConfig AuthConfiguration) func(c echo.Context) error { + return func(c echo.Context) error { + model := shared.AuthInfoModel{ + Scheme: authConfig.Scheme, + } + + if model.Scheme == AuthJWT { + model.AuthURL = shared.AuthRoute() + } + + return c.JSON(http.StatusOK, model) + } +} diff --git a/packages/authentication/shared/routes.go b/packages/authentication/shared/routes.go index 25dffdbb62..f71811051d 100644 --- a/packages/authentication/shared/routes.go +++ b/packages/authentication/shared/routes.go @@ -4,10 +4,6 @@ func AuthRoute() string { return "/auth" } -func AuthRouteSuccess() string { - return "/auth/success" -} - func AuthInfoRoute() string { return "/auth/info" } diff --git a/packages/authentication/status.go b/packages/authentication/status.go deleted file mode 100644 index 046269882a..0000000000 --- a/packages/authentication/status.go +++ /dev/null @@ -1,33 +0,0 @@ -package authentication - -import ( - "net/http" - - "github.com/labstack/echo/v4" - - "github.com/iotaledger/wasp/packages/authentication/shared" -) - -type StatusWebAPIModel struct { - config AuthConfiguration -} - -func (a *StatusWebAPIModel) handleAuthenticationStatus(c echo.Context) error { - model := shared.AuthInfoModel{ - Scheme: a.config.Scheme, - } - - if model.Scheme == AuthJWT { - model.AuthURL = shared.AuthRoute() - } - - return c.JSON(http.StatusOK, model) -} - -func addAuthenticationStatus(webAPI WebAPI, config AuthConfiguration) { - c := &StatusWebAPIModel{ - config: config, - } - - webAPI.GET(shared.AuthInfoRoute(), c.handleAuthenticationStatus) -} diff --git a/packages/authentication/strategy.go b/packages/authentication/strategy.go deleted file mode 100644 index 0f61e0fea9..0000000000 --- a/packages/authentication/strategy.go +++ /dev/null @@ -1,171 +0,0 @@ -package authentication - -import ( - "fmt" - "net/http" - "time" - - "github.com/labstack/echo/v4" - "github.com/pangpanglabs/echoswagger/v2" - - "github.com/iotaledger/wasp/packages/authentication/shared" - "github.com/iotaledger/wasp/packages/registry" - "github.com/iotaledger/wasp/packages/users" -) - -const ( - AuthJWT = "jwt" - AuthBasic = "basic" - AuthIPWhitelist = "ip" - AuthNone = "none" -) - -type JWTAuthConfiguration struct { - Duration time.Duration `default:"24h" usage:"jwt token lifetime"` -} - -type BasicAuthConfiguration struct { - Username string `default:"wasp" usage:"the username which grants access to the service"` -} - -type IPWhiteListAuthConfiguration struct { - Whitelist []string `default:"0.0.0.0" usage:"a list of ips that are allowed to access the service"` -} - -type AuthConfiguration struct { - Scheme string `default:"ip" usage:"selects which authentication to choose"` - - JWTConfig JWTAuthConfiguration `name:"jwt" usage:"defines the jwt configuration"` - BasicAuthConfig BasicAuthConfiguration `name:"basic" usage:"defines the basic auth configuration"` - IPWhitelistConfig IPWhiteListAuthConfiguration `name:"ip" usage:"defines the whitelist configuration"` -} - -type WebAPI interface { - GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route - POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route - Use(middleware ...echo.MiddlewareFunc) -} - -func AddNoneAuth(webAPI WebAPI) { - // Adds a middleware to set the authContext to authenticated. - // All routes will be open to everyone, so use it in private environments only. - // Handle with care! - noneFunc := func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - authContext := c.Get("auth").(*AuthContext) - - authContext.isAuthenticated = true - - return next(c) - } - } - - webAPI.Use(noneFunc) -} - -func AddV1Authentication( - webAPI WebAPI, - userManager *users.UserManager, - nodeIdentityProvider registry.NodeIdentityProvider, - authConfig AuthConfiguration, - claimValidator ClaimValidator, -) { - addAuthContext(webAPI, authConfig) - - switch authConfig.Scheme { - case AuthBasic: - AddBasicAuth(webAPI, userManager) - case AuthJWT: - nodeIdentity := nodeIdentityProvider.NodeIdentity() - privateKey := nodeIdentity.GetPrivateKey().AsBytes() - - // The primary claim is the one mandatory claim that gives access to api/webapi/alike - jwtAuth, authMiddleware := AddJWTAuth(authConfig.JWTConfig, privateKey, userManager, claimValidator) - - authHandler := &AuthHandler{Jwt: jwtAuth, UserManager: userManager} - webAPI.POST(shared.AuthRoute(), authHandler.CrossAPIAuthHandler) - webAPI.Use(authMiddleware()) - - case AuthIPWhitelist: - AddIPWhiteListAuth(webAPI, authConfig.IPWhitelistConfig) - - case AuthNone: - AddNoneAuth(webAPI) - - default: - panic(fmt.Sprintf("Unknown auth scheme %s", authConfig.Scheme)) - } - - addAuthenticationStatus(webAPI, authConfig) -} - -// TODO: After deprecating V1 we can slim down this whole strategy handler. -// It is currently needed as the current authentication scheme does not support echoSwagger, -// which leaves authentication out of the client code generator. -// After v1 gets removed: -// * Get rid off basic/ip auth and only keeping 'none' and 'JWT' -// * Properly document the routes with echoSwagger -// * Keep only one AddAuthentication method - -func AddV2Authentication(apiRoot echoswagger.ApiRoot, - userManager *users.UserManager, - nodeIdentityProvider registry.NodeIdentityProvider, - authConfig AuthConfiguration, - claimValidator ClaimValidator, -) func() echo.MiddlewareFunc { - echoRoot := apiRoot.Echo() - authGroup := apiRoot.Group("auth", "") - - addAuthContext(echoRoot, authConfig) - - c := &StatusWebAPIModel{ - config: authConfig, - } - - authGroup.GET(shared.AuthInfoRoute(), c.handleAuthenticationStatus). - AddResponse(http.StatusOK, "Login was successful", shared.AuthInfoModel{}, nil). - SetOperationId("authInfo"). - SetSummary("Get information about the current authentication mode") - - switch authConfig.Scheme { - case AuthJWT: - nodeIdentity := nodeIdentityProvider.NodeIdentity() - privateKey := nodeIdentity.GetPrivateKey().AsBytes() - - // The primary claim is the one mandatory claim that gives access to api/webapi/alike - jwtAuth, jwtMiddleware := AddJWTAuth(authConfig.JWTConfig, privateKey, userManager, claimValidator) - - authHandler := &AuthHandler{Jwt: jwtAuth, UserManager: userManager} - authGroup.POST(shared.AuthRoute(), authHandler.CrossAPIAuthHandler). - AddParamBody(shared.LoginRequest{}, "", "The login request", true). - AddResponse(http.StatusUnauthorized, "Unauthorized (Wrong permissions, missing token)", nil, nil). - AddResponse(http.StatusOK, "Login was successful", shared.LoginResponse{}, nil). - SetOperationId("authenticate"). - SetSummary("Authenticate towards the node") - - return jwtMiddleware - - case AuthNone: - AddNoneAuth(echoRoot) - authGroup.POST(shared.AuthRoute(), nil). - AddResponse(http.StatusMethodNotAllowed, "auth type: none", nil, nil) - return nil - - default: - panic(fmt.Sprintf("Unknown auth scheme %s", authConfig.Scheme)) - } -} - -func addAuthContext(webAPI WebAPI, config AuthConfiguration) { - webAPI.Use(func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - cc := &AuthContext{ - scheme: config.Scheme, - } - - c.Set("auth", cc) - - return next(c) - } - }) -} diff --git a/packages/authentication/validate_middleware.go b/packages/authentication/validate_middleware.go new file mode 100644 index 0000000000..47ef38be51 --- /dev/null +++ b/packages/authentication/validate_middleware.go @@ -0,0 +1,114 @@ +package authentication + +import ( + "crypto/subtle" + "fmt" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + echojwt "github.com/labstack/echo-jwt/v4" + "github.com/labstack/echo/v4" + + "github.com/iotaledger/wasp/packages/authentication/shared" + "github.com/iotaledger/wasp/packages/users" +) + +var DefaultJWTDuration time.Duration + +func GetJWTAuthMiddleware( + config JWTAuthConfiguration, + privateKey []byte, + userManager *users.UserManager, +) (*JWTAuth, echo.MiddlewareFunc) { + duration := config.Duration + // If durationHours is 0, we set 24h as the default duration + if duration == 0 { + duration = DefaultJWTDuration + } + + // FIXME: replace "wasp" as nodeID + jwtAuth := NewJWTAuth(duration, "wasp", privateKey) + + authMiddleware := echojwt.WithConfig(echojwt.Config{ + ContextKey: JWTContextKey, + NewClaimsFunc: func(c echo.Context) jwt.Claims { + return &WaspClaims{} + }, + Skipper: func(c echo.Context) bool { + path := c.Request().URL.Path + if path == "/" || + strings.HasSuffix(path, shared.AuthRoute()) || + strings.HasSuffix(path, shared.AuthInfoRoute()) || + strings.HasPrefix(path, "/doc") { + return true + } + + return false + }, + SigningKey: jwtAuth.secret, + TokenLookup: "header:Authorization:Bearer ,cookie:jwt", + ParseTokenFunc: func(c echo.Context, auth string) (interface{}, error) { + keyFunc := func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + + return jwtAuth.secret, nil + } + + token, err := jwt.ParseWithClaims( + auth, + &WaspClaims{}, + keyFunc, + jwt.WithValidMethods([]string{"HS256"}), + ) + if err != nil { + return nil, err + } + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + claims, ok := token.Claims.(*WaspClaims) + if !ok { + return nil, fmt.Errorf("wrong JWT claim type") + } + + audience, err := claims.GetAudience() + if err != nil { + return nil, err + } + b, err := audience.MarshalJSON() + if err != nil { + return nil, err + } + if subtle.ConstantTimeCompare(b, []byte(fmt.Sprintf("[%q]", jwtAuth.nodeID))) == 0 { + return nil, fmt.Errorf("not in audience") + } + + userMap := userManager.Users() + if _, ok := userMap[claims.Subject]; !ok { + return nil, fmt.Errorf("invalid subject") + } + + authContext := c.Get("auth").(*AuthContext) + authContext.claims = claims + + return token, nil + }, + }) + + return jwtAuth, authMiddleware +} + +func GetNoneAuthMiddleware() echo.MiddlewareFunc { + // Adds a middleware to set the authContext to authenticated. + // All routes will be open to everyone, so use it in private environments only. + // Handle with care! + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + return next(c) + } + } +} diff --git a/packages/authentication/validate_permissions.go b/packages/authentication/validate_permissions.go index 2c5364b1fa..88c8fe4d3b 100644 --- a/packages/authentication/validate_permissions.go +++ b/packages/authentication/validate_permissions.go @@ -28,10 +28,6 @@ func ValidatePermissions(permissions []string) func(next echo.HandlerFunc) echo. return next(e) } - if !authContext.IsAuthenticated() { - return e.JSON(http.StatusUnauthorized, ValidationError{Error: "Invalid token"}) - } - for _, permission := range permissions { if !authContext.claims.HasPermission(permission) { return e.JSON(http.StatusUnauthorized, ValidationError{MissingPermission: permission, Error: "Missing permission"}) diff --git a/packages/webapi/api.go b/packages/webapi/api.go index 526a672625..b6e4b37e9b 100644 --- a/packages/webapi/api.go +++ b/packages/webapi/api.go @@ -48,7 +48,7 @@ func AddHealthEndpoint(server echoswagger.ApiRoot, chainService interfaces.Chain SetSummary("Returns 200 if the node is healthy.") } -func loadControllers(server echoswagger.ApiRoot, mocker *Mocker, controllersToLoad []interfaces.APIController, authMiddleware func() echo.MiddlewareFunc) { +func loadControllers(server echoswagger.ApiRoot, mocker *Mocker, controllersToLoad []interfaces.APIController, authMiddleware echo.MiddlewareFunc) { for _, controller := range controllersToLoad { group := server.Group(controller.Name(), fmt.Sprintf("/v%d/", APIVersion)) controller.RegisterPublic(group, mocker) @@ -66,7 +66,7 @@ func loadControllers(server echoswagger.ApiRoot, mocker *Mocker, controllersToLo } if authMiddleware != nil { - group.EchoGroup().Use(authMiddleware()) + group.EchoGroup().Use(authMiddleware) } controller.RegisterAdmin(adminGroup, mocker) @@ -110,13 +110,7 @@ func Init( userService := services.NewUserService(userManager) // -- - claimValidator := func(claims *authentication.WaspClaims) bool { - // The v2 api uses another way of permission handling, so we can always return true here. - // Permissions are now validated at the route level. See the webapi/v2/controllers/*/controller.go routes. - return true - } - - authMiddleware := authentication.AddV2Authentication(server, userManager, nodeIdentityProvider, authConfig, claimValidator) + authMiddleware := authentication.AddAuthentication(server, userManager, nodeIdentityProvider, authConfig, mocker) controllersToLoad := []interfaces.APIController{ chain.NewChainController(logger, chainService, committeeService, evmService, nodeService, offLedgerService, registryService), diff --git a/packages/webapi/models/mock/AuthInfoModel.json b/packages/webapi/models/mock/AuthInfoModel.json new file mode 100644 index 0000000000..8aca4cda59 --- /dev/null +++ b/packages/webapi/models/mock/AuthInfoModel.json @@ -0,0 +1,4 @@ +{ + "scheme": "jwt", + "authURL": "/auth" +} \ No newline at end of file diff --git a/packages/webapi/models/mock/LoginRequest.json b/packages/webapi/models/mock/LoginRequest.json new file mode 100644 index 0000000000..74d65e01e8 --- /dev/null +++ b/packages/webapi/models/mock/LoginRequest.json @@ -0,0 +1,4 @@ +{ + "username":"wasp", + "password":"wasp" +} \ No newline at end of file diff --git a/packages/webapi/models/mock/LoginResponse.json b/packages/webapi/models/mock/LoginResponse.json new file mode 100644 index 0000000000..02fe1cf4aa --- /dev/null +++ b/packages/webapi/models/mock/LoginResponse.json @@ -0,0 +1,3 @@ +{ + "jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ3YXNwIiwic3ViIjoid2FzcCIsImF1ZCI6WyJ3YXNwIl0sImV4cCI6MTY4OTk1MTAyNCwibmJmIjoxNjg5ODY0NjI0LCJpYXQiOjE2ODk4NjQ2MjQsImp0aSI6IjE2ODk4NjQ2MjQiLCJwZXJtaXNzaW9ucyI6eyJ3cml0ZSI6e319fQ.LNUuTaoRjEPQyD2nQ00O6NeadiG7nmOEyVIQmGNb1a0" +} \ No newline at end of file diff --git a/tools/cluster/templates/waspconfig.go b/tools/cluster/templates/waspconfig.go index fae6698a82..6de466792b 100644 --- a/tools/cluster/templates/waspconfig.go +++ b/tools/cluster/templates/waspconfig.go @@ -118,14 +118,6 @@ var WaspConfig = ` "scheme": "none", "jwt": { "duration": "24h" - }, - "basic": { - "username": "wasp" - }, - "ip": { - "whitelist": [ - "0.0.0.0" - ] } }, "limits": { From 32a009343627b458d36bf5416f04d85a9371301c Mon Sep 17 00:00:00 2001 From: Yang Hau Date: Thu, 27 Jul 2023 13:28:16 +0300 Subject: [PATCH 2/2] feat: Allow jwt in cluster tests --- tools/cluster/cluster.go | 39 +++++++++++++++++++++++---- tools/cluster/config.go | 1 + tools/cluster/templates/waspconfig.go | 3 ++- tools/cluster/tests/wasp-cli_test.go | 15 +++++++++++ 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/tools/cluster/cluster.go b/tools/cluster/cluster.go index f114bf2d24..2c159e4207 100644 --- a/tools/cluster/cluster.go +++ b/tools/cluster/cluster.go @@ -124,13 +124,35 @@ func (clu *Cluster) AddTrustedNode(peerInfo apiclient.PeeringTrustRequest, onNod return nil } -func (clu *Cluster) TrustAll() error { +func (clu *Cluster) Login() ([]string, error) { + allNodes := clu.Config.AllNodes() + jwtTokens := make([]string, len(allNodes)) + for ni := range allNodes { + res, _, err := clu.WaspClient(allNodes[ni]).AuthApi.Authenticate(context.Background()). + LoginRequest(*apiclient.NewLoginRequest("wasp", "wasp")). + Execute() //nolint:bodyclose // false positive + if err != nil { + return nil, err + } + jwtTokens[ni] = "Bearer " + res.Jwt + } + return jwtTokens, nil +} + +func (clu *Cluster) TrustAll(jwtTokens ...string) error { allNodes := clu.Config.AllNodes() allPeers := make([]*apiclient.PeeringNodeIdentityResponse, len(allNodes)) + clients := make([]*apiclient.APIClient, len(allNodes)) + for ni := range allNodes { + clients[ni] = clu.WaspClient(allNodes[ni]) + if jwtTokens != nil { + clients[ni].GetConfig().AddDefaultHeader("Authorization", jwtTokens[ni]) + } + } for ni := range allNodes { var err error //nolint:bodyclose // false positive - if allPeers[ni], _, err = clu.WaspClient(allNodes[ni]).NodeApi.GetPeeringIdentity(context.Background()).Execute(); err != nil { + if allPeers[ni], _, err = clients[ni].NodeApi.GetPeeringIdentity(context.Background()).Execute(); err != nil { return err } } @@ -140,7 +162,7 @@ func (clu *Cluster) TrustAll() error { if ni == pi { continue // dont trust self } - if _, err = clu.WaspClient(allNodes[ni]).NodeApi.TrustPeer(context.Background()).PeeringTrustRequest( + if _, err = clients[ni].NodeApi.TrustPeer(context.Background()).PeeringTrustRequest( apiclient.PeeringTrustRequest{ Name: fmt.Sprintf("%d", pi), PublicKey: allPeers[pi].PublicKey, @@ -534,11 +556,18 @@ func (clu *Cluster) StartAndTrustAll(dataPath string) error { return fmt.Errorf("data path %s does not exist", dataPath) } - if err := clu.Start(); err != nil { + if err = clu.Start(); err != nil { return err } - if err := clu.TrustAll(); err != nil { + var jwtTokens []string + if clu.Config.Wasp[0].AuthScheme == "jwt" { + if jwtTokens, err = clu.Login(); err != nil { + return err + } + } + + if err := clu.TrustAll(jwtTokens...); err != nil { return err } diff --git a/tools/cluster/config.go b/tools/cluster/config.go index 2d44cfb822..cf57d2732f 100644 --- a/tools/cluster/config.go +++ b/tools/cluster/config.go @@ -29,6 +29,7 @@ func (w *WaspConfig) WaspConfigTemplateParams(i int) templates.WaspConfigParams MetricsPort: w.FirstMetricsPort + i, OffledgerBroadcastUpToNPeers: 10, PruningMinStatesToKeep: 10000, + AuthScheme: "none", } } diff --git a/tools/cluster/templates/waspconfig.go b/tools/cluster/templates/waspconfig.go index 6de466792b..6c05368fb9 100644 --- a/tools/cluster/templates/waspconfig.go +++ b/tools/cluster/templates/waspconfig.go @@ -17,6 +17,7 @@ type WaspConfigParams struct { ValidatorKeyPair *cryptolib.KeyPair ValidatorAddress string // bech32 encoded address of ValidatorKeyPair PruningMinStatesToKeep int + AuthScheme string } var WaspConfig = ` @@ -115,7 +116,7 @@ var WaspConfig = ` "enabled": true, "bindAddress": "0.0.0.0:{{.APIPort}}", "auth": { - "scheme": "none", + "scheme": "{{.AuthScheme}}", "jwt": { "duration": "24h" } diff --git a/tools/cluster/tests/wasp-cli_test.go b/tools/cluster/tests/wasp-cli_test.go index 8050058133..cc0eb377cb 100644 --- a/tools/cluster/tests/wasp-cli_test.go +++ b/tools/cluster/tests/wasp-cli_test.go @@ -45,6 +45,21 @@ func TestWaspCLINoChains(t *testing.T) { require.Contains(t, out[0], "Total 0 chain(s)") } +func TestWaspAuth(t *testing.T) { + w := newWaspCLITest(t, waspClusterOpts{ + modifyConfig: func(nodeIndex int, configParams templates.WaspConfigParams) templates.WaspConfigParams { + configParams.AuthScheme = "jwt" + return configParams + }, + }) + _, err := w.Run("chain", "list", "--node=0", "--node=0") + require.Error(t, err) + out := w.MustRun("auth", "login", "--node=0", "-u=wasp", "-p=wasp") + require.Equal(t, "Successfully authenticated", out[1]) + out = w.MustRun("chain", "list", "--node=0", "--node=0") + require.Contains(t, out[0], "Total 0 chain(s)") +} + func TestWaspCLI1Chain(t *testing.T) { w := newWaspCLITest(t)