Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/implement backend authentication #68

Merged
merged 20 commits into from
Mar 8, 2024

Conversation

Zheng-Zhi-Qiang
Copy link
Collaborator

@Zheng-Zhi-Qiang Zheng-Zhi-Qiang commented Feb 18, 2024

Description

In this PR, I created the authentication and authorization middleware to authenticate requests in the backend.

*Assumptions:

  • All request will have to be authenticated first. Will not store any cookies for now.
  • To differentiate admins from users, will look for 'approve:pipeline_step' permission in user permissions

@Zheng-Zhi-Qiang Zheng-Zhi-Qiang added backend Backend related feature A new feature labels Feb 18, 2024
@Zheng-Zhi-Qiang Zheng-Zhi-Qiang added this to the M2: More features milestone Feb 18, 2024
@Zheng-Zhi-Qiang Zheng-Zhi-Qiang self-assigned this Feb 18, 2024
@Zheng-Zhi-Qiang Zheng-Zhi-Qiang linked an issue Feb 18, 2024 that may be closed by this pull request
Copy link
Owner

@joshtyf joshtyf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me do more research before approving this

Comment on lines 22 to 23
accessToken := r.Header.Get("Authorization")
session, _ := store.Get(r, accessToken)
Copy link
Owner

@joshtyf joshtyf Feb 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typically, the value in this header will be Bearer <token>. For example, using auth0, typically the frontend will get the token of type Bearer. They will then form the Bearer <token> string and pass it to the backend. You can see it from the React example provided directly from Auth0 https://auth0.com/docs/quickstart/spa/react/02-calling-an-api

So you will probably need to do some string parsing to get the token from the Authorization header.

logger.Error("[Authorization] Unable to verify token", map[string]interface{}{"err": err})
authError := &api.HandlerError{Error: errors.New("unable to verify token"), Code: http.StatusUnauthorized}
w.WriteHeader(authError.Code)
json.NewEncoder(w).Encode(authError)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

encoding the error like this will not work. see my PR #65 description (or see this stack overflow answer https://stackoverflow.com/questions/44989924/golang-error-types-are-empty-when-encoded-to-json)

ClientSecret: os.Getenv("AUTH0_CLIENT_SECRET"),
RedirectURL: os.Getenv("AUTH0_CALLBACK_URL"),
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile"},
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we be setting the scope here? I'm not too familiar

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was based off their quick start guide so we just stick with this first until changes are required bah since it doesn't seem to create any immediate issues in my mind

Copy link
Owner

@joshtyf joshtyf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Key changes required:

  1. I believe we should use access tokens instead of ID tokens
  2. We should also verify the scope of the request, issuer domain as well as the audience of the token.

You can cross reference with the official auth0 Golang guide here

}

func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
rawIDToken, ok := token.Extra("id_token").(string)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this Extra("id_token") about actually? Can you provide a reference/documentation for me to take a look at the id_token field that is present in oauth2 JWT?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I just read up more on the different token types for JWT. There is an ID token and Access token type. ID tokens should be used for authentication (i.e. a user is a valid user) and access tokens should be used for authorisation (i.e. a user is valid and is authorised to perform a certain action).

from https://auth0.com/docs/secure/tokens/id-tokens: ID Tokens should never be used to obtain direct access to APIs or to make authorization decisions.

So you'll need to consider which APIs require an ID token, and which ones require an Access token and use the correct middleware accordingly. In our scenario, I believe we should be using Access tokens instead.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The quick start guide I was using is post login when I created the application. The guide only showed how to integrate authentication and not authorisation, hence the discrepancy here I think. Ill just refer to the guide you mentioned.

func IsAuthenticated(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")
accessToken := strings.TrimSpace(strings.Replace(r.Header.Get("Authorization"), "Bearer", "", 1))
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not too sure about the security implications about this method of doing things.

I think it's better to reject if the header doesn't contain the prefix Bearer, even if the token is correct. A better way is to do a strings.Split and verify 1) there is a Bearer prefix 2) there should only be the Bearer string and the token string after the split

@@ -35,6 +35,7 @@ func addRoutes(r *mux.Router) {
func New() http.Handler {
router := mux.NewRouter()
addRoutes(router)
router.Use(IsAuthenticated)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have 2 options:

  1. Create a global middleware that authenticates every request; create a authorisation middleware that we chain on every handler that requires authorisation (such as creating new pipelines, approvals, etc.)
  2. Don't use a global middleware for authentication. On every handler (that requires authentication), we will chain this authentication middleware.

Method 1 reduces the amount of code to write but it assumes that every handler requires authentication. It might just be better to opt for method 2, even though we have to repeat ourselves every time. Thoughts?

reference: https://auth0.com/blog/id-token-access-token-what-is-the-difference/

Copy link
Collaborator Author

@Zheng-Zhi-Qiang Zheng-Zhi-Qiang Feb 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thing is I can't think of a situation where authentication is not required. The only possibility would be a server side landing page, but since our backend is purely just API, there should not be any paths where authorisation middleware is not required. In that case, since the only differentiation is between authorisation for different roles, I think we just do two bah

compose.yaml Outdated
@@ -20,6 +20,7 @@ services:
- be
build:
context: backend
env_file: .env
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you shouldn't need this field as .env is the default env_file that will be sourced.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't source from the .env for me though. I needed to add it in to see the env variables

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where are you running your docker command from? your .env should be located in the root of the project. run your docker commands from the root as well.

@Zheng-Zhi-Qiang Zheng-Zhi-Qiang requested a review from joshtyf March 1, 2024 11:12
Copy link
Owner

@joshtyf joshtyf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code looks fine except the docker compose file and my comments. But you haven't used the middleware?

Comment on lines 32 to 42
// HasScope checks whether our claims have a specific scope.
func (c CustomClaims) HasScope(expectedScope string) bool {
result := strings.Split(c.Scope, " ")
for i := range result {
if result[i] == expectedScope {
return true
}
}

return false
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why didn't you keep this method in the middleware.go file?

jwtValidator.ValidateToken,
jwtmiddleware.WithErrorHandler(errorHandler),
)
logger.Info("Validating", nil)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we get a better log message here?

logger.Info("Authorizing", nil)
token := r.Context().Value(jwtmiddleware.ContextKey{}).(*validator.ValidatedClaims)
claims := token.CustomClaims.(*CustomClaims)
if !claims.HasScope("test") {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the scope still be test?

Copy link
Owner

@joshtyf joshtyf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some minor changes, other than that should be good

@@ -15,21 +15,21 @@ func addRoutes(r *mux.Router) {
}

// Health Check
r.Handle("/api/healthcheck", handleHealthCheck()).Methods("GET")
r.Handle("/api/healthcheck", isAuthenticated(isAuthorisedAdmin(handleHealthCheck()))).Methods("GET")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to authorise the health check.

Comment on lines 30 to 31
r.Handle("/api/pipeline", isAuthenticated(isAuthorisedUser(handleGetAllPipelines(mongoClient)))).Methods("GET")
r.Handle("/api/pipeline/{pipelineId}", isAuthenticated(isAuthorisedUser(handleGetPipeline(mongoClient)))).Methods("GET")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, basic users shouldn't be able to see the pipeline. They should only be able to see the service request that uses the latest version of the pipeline

Comment on lines 43 to 44
// Validate does nothing for this example, but we need
// it to satisfy validator.CustomClaims interface.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can remove this comment? or make it more relevant to our use case

@Zheng-Zhi-Qiang Zheng-Zhi-Qiang requested a review from joshtyf March 7, 2024 15:51
Copy link
Owner

@joshtyf joshtyf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Except for the HasPermission in helper.go

Comment on lines 32 to 43
// HasPermission checks whether our claims have a specific permission.
// In our case, since we are using this to check if user is admin, will be checking for approve:pipeline_step permission
func (c CustomClaims) HasPermission(expectedPermission string) bool {
result := strings.Split(c.Permissions, ",")
for i := range result {
if result[i] == expectedPermission {
return true
}
}

return false
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wait why you move this here? why didn't you keep it with middleware.go?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function is just a helper function to check if a permission exists, to me not really a middleware exactly

@Zheng-Zhi-Qiang Zheng-Zhi-Qiang merged commit 5a83b0a into main Mar 8, 2024
2 checks passed
@Zheng-Zhi-Qiang Zheng-Zhi-Qiang deleted the feature/implement-backend-authentication branch March 8, 2024 10:00
@Zheng-Zhi-Qiang Zheng-Zhi-Qiang linked an issue Mar 17, 2024 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
backend Backend related feature A new feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Integrate auth0 + google authentication Authenticate requests in backend
2 participants