Skip to content

Commit

Permalink
audit
Browse files Browse the repository at this point in the history
  • Loading branch information
hummerdmag committed Aug 16, 2024
1 parent 4164b0e commit 84eea0d
Show file tree
Hide file tree
Showing 14 changed files with 98 additions and 68 deletions.
6 changes: 5 additions & 1 deletion jwt/service/jwt_token_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,9 +343,13 @@ func (ts *JWTokenService) RefreshAccessToken(refreshToken model.Token, tokenPayl
return nil, ErrInvalidUser
}

requestedScopes := strings.Split(claims.Scopes, " ")

scopes := model.AllowedScopes(requestedScopes, user.Scopes, app.Offline)

token, err := ts.NewAccessToken(
user,
strings.Split(claims.Scopes, " "),
scopes,
app,
false,
tokenPayload)
Expand Down
1 change: 1 addition & 0 deletions model/slice.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package model

import "strings"

// SliceIntersect returns only items in as that are found in bs.
// simple intersection of two slices, with complexity: O(n^2)
// there is better algorithms around, this one is simple and scopes are usually 1-3 items in it
func SliceIntersect(a, b []string) []string {
Expand Down
13 changes: 13 additions & 0 deletions model/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,16 @@ type Claims struct {

// Full example of how to use JWT tokens:
// https://github.com/form3tech-oss/jwt-go/blob/master/cmd/jwt/app.go

func AllowedScopes(requestedScopes, userScopes []string, isOffline bool) []string {
scopes := []string{}
// if we requested any scope, let's provide all the scopes user has and requested
if len(requestedScopes) > 0 {
scopes = SliceIntersect(requestedScopes, userScopes)
}
if SliceContains(requestedScopes, "offline") && isOffline {
scopes = append(scopes, "offline")
}

return scopes
}
10 changes: 5 additions & 5 deletions storage/grpc/shared/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type GRPCServer struct {
func (m *GRPCServer) UserByPhone(ctx context.Context, in *proto.UserByPhoneRequest) (*proto.User, error) {
user, err := m.Impl.UserByPhone(in.Phone)
if err == model.ErrUserNotFound {
return toProto(user), status.Errorf(codes.NotFound, err.Error())
return toProto(user), status.Error(codes.NotFound, err.Error())
}
return toProto(user), err
}
Expand All @@ -31,31 +31,31 @@ func (m *GRPCServer) AddUserWithPassword(ctx context.Context, in *proto.AddUserW
func (m *GRPCServer) UserByID(ctx context.Context, in *proto.UserByIDRequest) (*proto.User, error) {
user, err := m.Impl.UserByID(in.Id)
if err == model.ErrUserNotFound {
return toProto(user), status.Errorf(codes.NotFound, err.Error())
return toProto(user), status.Error(codes.NotFound, err.Error())
}
return toProto(user), err
}

func (m *GRPCServer) UserByEmail(ctx context.Context, in *proto.UserByEmailRequest) (*proto.User, error) {
user, err := m.Impl.UserByEmail(in.Email)
if err == model.ErrUserNotFound {
return toProto(user), status.Errorf(codes.NotFound, err.Error())
return toProto(user), status.Error(codes.NotFound, err.Error())
}
return toProto(user), err
}

func (m *GRPCServer) UserByUsername(ctx context.Context, in *proto.UserByUsernameRequest) (*proto.User, error) {
user, err := m.Impl.UserByUsername(in.Username)
if err == model.ErrUserNotFound {
return toProto(user), status.Errorf(codes.NotFound, err.Error())
return toProto(user), status.Error(codes.NotFound, err.Error())
}
return toProto(user), err
}

func (m *GRPCServer) UserByFederatedID(ctx context.Context, in *proto.UserByFederatedIDRequest) (*proto.User, error) {
user, err := m.Impl.UserByFederatedID(in.Provider, in.Id)
if err == model.ErrUserNotFound {
return toProto(user), status.Errorf(codes.NotFound, err.Error())
return toProto(user), status.Error(codes.NotFound, err.Error())
}
return toProto(user), err
}
Expand Down
13 changes: 4 additions & 9 deletions web/api/2fa.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ func (ar *Router) ResendTFA() http.HandlerFunc {

scopes := strings.Split(token.Scopes(), " ")

authResult, err := ar.loginFlow(app, user, scopes, nil)
authResult, _, err := ar.loginFlow(app, user, scopes, nil)
if err != nil {
ar.Error(w, locale, http.StatusInternalServerError, l.APIInternalServerErrorWithError, err)
return
Expand Down Expand Up @@ -265,14 +265,7 @@ func (ar *Router) FinalizeTFA() http.HandlerFunc {
return
}

scopes := []string{}
// if we requested any scope, let's provide all the scopes user has and requested
if len(d.Scopes) > 0 {
scopes = model.SliceIntersect(d.Scopes, user.Scopes)
}
if model.SliceContains(d.Scopes, "offline") && app.Offline {
scopes = append(scopes, "offline")
}
scopes := model.AllowedScopes(d.Scopes, app.Scopes, app.Offline)

tokenPayload, err := ar.getTokenPayloadForApp(app, user.ID)
if err != nil {
Expand Down Expand Up @@ -312,6 +305,8 @@ func (ar *Router) FinalizeTFA() http.HandlerFunc {
}
}

journal(user.ID, app.ID, "2fa_login", scopes)

ar.server.Storages().User.UpdateLoginMetadata(user.ID)
ar.ServeJSON(w, locale, http.StatusOK, result)
}
Expand Down
8 changes: 8 additions & 0 deletions web/api/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package api

import "log"

func journal(userID, appID, action string, scopes []string) {
log.Printf("audit record | %s | userID=%s appID=%s scopes=%v\n",
action, userID, appID, scopes)
}
4 changes: 3 additions & 1 deletion web/api/federated_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ func (ar *Router) FederatedLoginComplete() http.HandlerFunc {
return
}

authResult, err := ar.loginFlow(app, user, fsess.Scopes, nil)
authResult, resultScopes, err := ar.loginFlow(app, user, fsess.Scopes, nil)
if err != nil {
ar.Error(w, locale, http.StatusInternalServerError, l.ErrorFederatedLoginError, err)
return
Expand All @@ -212,6 +212,8 @@ func (ar *Router) FederatedLoginComplete() http.HandlerFunc {
authResult.CallbackUrl = fsess.CallbackUrl
authResult.Scopes = fsess.Scopes

journal(user.ID, app.ID, "federated_login", resultScopes)

ar.ServeJSON(w, locale, http.StatusOK, authResult)
}
}
Expand Down
6 changes: 4 additions & 2 deletions web/api/federated_oidc_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ func (ar *Router) OIDCLoginComplete(useSession bool) http.HandlerFunc {
// map OIDC scopes to Identifo scopes
requestedScopes = mapScopes(app.OIDCSettings.ScopeMapping, requestedScopes)

authResult, err := ar.loginFlow(app, user, requestedScopes, nil)
authResult, resultScopes, err := ar.loginFlow(app, user, requestedScopes, nil)
if err != nil {
ar.Error(w, locale, http.StatusInternalServerError, l.ErrorFederatedLoginError, err)
return
Expand All @@ -247,9 +247,11 @@ func (ar *Router) OIDCLoginComplete(useSession bool) http.HandlerFunc {
authResult.CallbackUrl = fsess.CallbackUrl
}

authResult.Scopes = requestedScopes
authResult.Scopes = resultScopes
authResult.ProviderData = *providerData

journal(user.ID, app.ID, "oidc_login", resultScopes)

ar.ServeJSON(w, locale, http.StatusOK, authResult)
}
}
Expand Down
19 changes: 15 additions & 4 deletions web/api/federated_oidc_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ import (
)

var testApp = model.AppData{
ID: "test_app",
Active: true,
Type: model.Web,
ID: "test_app",
Active: true,
Offline: true,
Type: model.Web,
OIDCSettings: model.OIDCSettings{
ProviderName: "test",
ClientID: "test",
Expand Down Expand Up @@ -62,6 +63,8 @@ func init() {
panic(err)
}

testServer.Storages().App.CreateApp(testApp)

rs := api.RouterSettings{
LoginWith: model.LoginWith{
FederatedOIDC: true,
Expand Down Expand Up @@ -228,12 +231,20 @@ func Test_Router_OIDCLogin_Complete_ByEmail(t *testing.T) {
}

func claimsFromResponse(t *testing.T, response []byte) jwt.MapClaims {
return claimsFromJSONResponse(t, "access_token", response)
}

func refreshClaimsFromResponse(t *testing.T, response []byte) jwt.MapClaims {
return claimsFromJSONResponse(t, "refresh_token", response)
}

func claimsFromJSONResponse(t *testing.T, token_field string, response []byte) jwt.MapClaims {
var token map[string]any

err := json.Unmarshal(response, &token)
require.NoError(t, err)

at := token["access_token"].(string)
at := token[token_field].(string)
require.NotEmpty(t, at)

c := jwt.MapClaims{}
Expand Down
10 changes: 6 additions & 4 deletions web/api/impersonate_as.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,20 @@ func (ar *Router) ImpersonateAs() http.HandlerFunc {
return
}

userID := tokenFromContext(r.Context()).UserID()
userID := tokenFromContext(ctx).UserID()
adminUser, err := ar.server.Storages().User.UserByID(userID)
if err != nil {
ar.Error(w, locale, http.StatusUnauthorized, l.ErrorStorageFindUserIDError, userID, err)
return
}

log.Println("admin for impersonation", adminUser.ID, adminUser.Scopes)

d := impersonateData{}
if ar.MustParseJSON(w, r, &d) != nil {
return
}

log.Println("admin for impersonation", adminUser.ID, adminUser.Scopes, "as", d.UserID)

user, err := ar.server.Storages().User.UserByID(d.UserID)
if err != nil {
ar.Error(w, locale, http.StatusUnauthorized, l.ErrorStorageFindUserIDError, d.UserID, err)
Expand All @@ -63,7 +63,7 @@ func (ar *Router) ImpersonateAs() http.HandlerFunc {
"impersonated_by": adminUser.ID,
}

authResult, err := ar.loginFlow(app, user, nil, ap)
authResult, resultScopes, err := ar.loginFlow(app, user, nil, ap)
if err != nil {
ar.Error(w, locale, http.StatusInternalServerError, l.ErrorAPILoginError, err)
return
Expand All @@ -72,6 +72,8 @@ func (ar *Router) ImpersonateAs() http.HandlerFunc {
// do not allow refresh for impersonated user
authResult.RefreshToken = ""

journal(userID, app.ID, "impersonated", resultScopes)

ar.ServeJSON(w, locale, http.StatusOK, authResult)
}
}
Expand Down
35 changes: 18 additions & 17 deletions web/api/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,14 @@ func (ar *Router) LoginWithPassword() http.HandlerFunc {
return
}

authResult, err := ar.loginFlow(app, user, ld.Scopes, nil)
authResult, resultScopes, err := ar.loginFlow(app, user, ld.Scopes, nil)
if err != nil {
ar.Error(w, locale, http.StatusInternalServerError, l.ErrorAPILoginError, err)
return
}

journal(authResult.User.ID, app.ID, "pass_login", resultScopes)

ar.ServeJSON(w, locale, http.StatusOK, authResult)
}
}
Expand Down Expand Up @@ -304,7 +306,13 @@ func (ar *Router) getTokenPayloadService(app model.AppData) (model.TokenPayloadP

// loginUser creates and returns access token for a user.
// createRefreshToken boolean param tells if we should issue refresh token as well.
func (ar *Router) loginUser(user model.User, scopes []string, app model.AppData, createRefreshToken, require2FA bool, tokenPayload map[string]interface{}) (string, string, error) {
func (ar *Router) loginUser(
user model.User,
scopes []string,
app model.AppData,
createRefreshToken, require2FA bool,
tokenPayload map[string]interface{},
) (string, string, error) {
token, err := ar.server.Services().Token.NewAccessToken(user, scopes, app, require2FA, tokenPayload)
if err != nil {
return "", "", err
Expand Down Expand Up @@ -335,33 +343,26 @@ func (ar *Router) loginFlow(
user model.User,
requestedScopes []string,
additionalPayload map[string]any,
) (AuthResponse, error) {
) (AuthResponse, []string, error) {
// check if the user has the scope, that allows to login to the app
// user has to have at least one scope app expecting
if len(app.Scopes) > 0 && len(model.SliceIntersect(app.Scopes, user.Scopes)) == 0 {
return AuthResponse{}, errors.New("user does not have required scope for the app")
return AuthResponse{}, nil, errors.New("user does not have required scope for the app")
}

// Do login flow.
scopes := []string{}
// if we requested any scope, let's provide all the scopes user has and requested
if len(requestedScopes) > 0 {
scopes = model.SliceIntersect(requestedScopes, user.Scopes)
}
if model.SliceContains(requestedScopes, "offline") && app.Offline {
scopes = append(scopes, "offline")
}
scopes := model.AllowedScopes(requestedScopes, user.Scopes, app.Offline)

// Check if we should require user to authenticate with 2FA.
require2FA, enabled2FA, err := ar.check2FA(app.TFAStatus, ar.tfaType, user)
if !require2FA && enabled2FA && err != nil {
return AuthResponse{}, err
return AuthResponse{}, nil, err
}

offline := contains(scopes, model.OfflineScope)
tokenPayload, err := ar.getTokenPayloadForApp(app, user.ID)
if err != nil {
return AuthResponse{}, err
return AuthResponse{}, nil, err
}

if tokenPayload == nil {
Expand All @@ -374,7 +375,7 @@ func (ar *Router) loginFlow(

accessToken, refreshToken, err := ar.loginUser(user, scopes, app, offline, require2FA, tokenPayload)
if err != nil {
return AuthResponse{}, err
return AuthResponse{}, nil, err
}

result := AuthResponse{
Expand All @@ -386,15 +387,15 @@ func (ar *Router) loginFlow(

if require2FA && enabled2FA {
if err := ar.sendOTPCode(app, user); err != nil {
return AuthResponse{}, err
return AuthResponse{}, nil, err
}
} else {
ar.server.Storages().User.UpdateLoginMetadata(user.ID)
}

user = user.Sanitized()
result.User = user
return result, nil
return result, scopes, nil
}

type impersonateData struct {
Expand Down
20 changes: 10 additions & 10 deletions web/api/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ func (ar *Router) Logout() http.HandlerFunc {
result := map[string]string{"result": "ok"}

return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
locale := r.Header.Get("Accept-Language")

accessTokenBytes, ok := r.Context().Value(model.TokenRawContextKey).([]byte)
accessToken := tokenFromContext(ctx)

accessTokenBytes, ok := ctx.Value(model.TokenRawContextKey).([]byte)
if !ok {
ar.logger.Println("Cannot fetch access token bytes from context")
ar.ServeJSON(w, locale, http.StatusNoContent, nil)
Expand Down Expand Up @@ -56,23 +59,20 @@ func (ar *Router) Logout() http.HandlerFunc {
}
}

journal(accessToken.Subject(), accessToken.Audience(), "logout", nil)

ar.ServeJSON(w, locale, http.StatusOK, result)
}
}

func (ar *Router) getTokenSubject(tokenString string) (string, error) {
claims := jwt.MapClaims{}

if _, err := jwt.ParseWithClaims(tokenString, claims, nil); err == nil {
return "", fmt.Errorf("Cannot parse token: %s", err)
}
claims := jwt.RegisteredClaims{}

sub, ok := claims["sub"].(string)
if !ok {
return "", fmt.Errorf("Cannot obtain token subject")
if _, err := jwt.ParseWithClaims(tokenString, &claims, nil); err == nil {
return "", fmt.Errorf("cannot parse token: %s", err)
}

return sub, nil
return claims.Subject, nil
}

func (ar *Router) revokeRefreshToken(refreshTokenString, accessTokenString string) error {
Expand Down
Loading

0 comments on commit 84eea0d

Please sign in to comment.