refactor: replace refresh & access token with auth token only
changes authentication to no longer use a refresh token & access token for accessing protected endpoints. Instead only an auth token is used. Before the login flow was: Login -> get refresh (stored as HttpOnly cookie) + access token (stored in memory) -> protected endpoint request (attach access token as Authorization header) -> access token expires in 15 minutes, so use refresh token to obtain new one when that happens now it looks like this: Login -> get auth token (stored as HttpOnly cookie) -> make protected endpont request (token sent) the reasoning for using the refresh + access token was to reduce DB calls, but in the end I don't think its worth the hassle.
This commit is contained in:
@ -8,7 +8,6 @@ import (
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jordanknott/taskcafe/internal/auth"
|
||||
"github.com/jordanknott/taskcafe/internal/db"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@ -54,10 +53,15 @@ type Setup struct {
|
||||
ConfirmToken string `json:"confirmToken"`
|
||||
}
|
||||
|
||||
type ValidateAuthTokenResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
UserID string `json:"userID"`
|
||||
}
|
||||
|
||||
// LoginResponseData is the response data for when a user logs in
|
||||
type LoginResponseData struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
Setup bool `json:"setup"`
|
||||
UserID string `json:"userID"`
|
||||
Complete bool `json:"complete"`
|
||||
}
|
||||
|
||||
// LogoutResponseData is the response data for when a user logs out
|
||||
@ -65,8 +69,8 @@ type LogoutResponseData struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// RefreshTokenResponseData is the response data for when an access token is refreshed
|
||||
type RefreshTokenResponseData struct {
|
||||
// AuthTokenResponseData is the response data for when an access token is refreshed
|
||||
type AuthTokenResponseData struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
|
||||
@ -76,93 +80,9 @@ type AvatarUploadResponseData struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// RefreshTokenHandler handles when a user attempts to refresh token
|
||||
func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userExists, err := h.repo.HasAnyUser(r.Context())
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
log.WithError(err).Error("issue while fetching if user accounts exist")
|
||||
return
|
||||
}
|
||||
|
||||
log.WithField("userExists", userExists).Info("checking if setup")
|
||||
if !userExists {
|
||||
w.Header().Set("Content-type", "application/json")
|
||||
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: "", Setup: true})
|
||||
return
|
||||
}
|
||||
|
||||
c, err := r.Cookie("refreshToken")
|
||||
if err != nil {
|
||||
if err == http.ErrNoCookie {
|
||||
log.Warn("no cookie")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.WithError(err).Error("unknown error")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
refreshTokenID := uuid.MustParse(c.Value)
|
||||
token, err := h.repo.GetRefreshTokenByID(r.Context(), refreshTokenID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
|
||||
log.WithError(err).WithFields(log.Fields{"refreshTokenID": refreshTokenID.String()}).Error("no tokens found")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.WithError(err).Error("token retrieve failure")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.GetUserAccountByID(r.Context(), token.UserID)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("user retrieve failure")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.Active {
|
||||
log.WithFields(log.Fields{
|
||||
"username": user.Username,
|
||||
}).Warn("attempt to refresh token with inactive user")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
refreshCreatedAt := time.Now().UTC()
|
||||
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
||||
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{token.UserID, refreshCreatedAt, refreshExpiresAt})
|
||||
|
||||
err = h.repo.DeleteRefreshTokenByID(r.Context(), token.TokenID)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("here 1")
|
||||
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted, user.RoleCode, h.SecurityConfig.Secret, h.SecurityConfig.AccessTokenExpiration)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("here 2")
|
||||
w.Header().Set("Content-type", "application/json")
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "refreshToken",
|
||||
Value: refreshTokenString.TokenID.String(),
|
||||
Expires: refreshExpiresAt,
|
||||
HttpOnly: true,
|
||||
})
|
||||
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, Setup: false})
|
||||
}
|
||||
|
||||
// LogoutHandler removes all refresh tokens to log out user
|
||||
func (h *TaskcafeHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("refreshToken")
|
||||
c, err := r.Cookie("authToken")
|
||||
if err != nil {
|
||||
if err == http.ErrNoCookie {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
@ -172,7 +92,7 @@ func (h *TaskcafeHandler) LogoutHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
refreshTokenID := uuid.MustParse(c.Value)
|
||||
err = h.repo.DeleteRefreshTokenByID(r.Context(), refreshTokenID)
|
||||
err = h.repo.DeleteAuthTokenByID(r.Context(), refreshTokenID)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
@ -216,87 +136,23 @@ func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
refreshCreatedAt := time.Now().UTC()
|
||||
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
||||
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
|
||||
authCreatedAt := time.Now().UTC()
|
||||
authExpiresAt := authCreatedAt.AddDate(0, 0, 1)
|
||||
authToken, err := h.repo.CreateAuthToken(r.Context(), db.CreateAuthTokenParams{user.UserID, authCreatedAt, authExpiresAt})
|
||||
|
||||
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.SecurityConfig.Secret, h.SecurityConfig.AccessTokenExpiration)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-type", "application/json")
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "refreshToken",
|
||||
Value: refreshTokenString.TokenID.String(),
|
||||
Expires: refreshExpiresAt,
|
||||
Name: "authToken",
|
||||
Value: authToken.TokenID.String(),
|
||||
Expires: authExpiresAt,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
})
|
||||
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
|
||||
}
|
||||
|
||||
// TODO: remove
|
||||
// InstallHandler creates first user on fresh install
|
||||
func (h *TaskcafeHandler) InstallHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if restricted, ok := r.Context().Value("restricted_mode").(auth.RestrictedMode); ok {
|
||||
if restricted != auth.InstallOnly {
|
||||
log.Warning("attempted to install without install only restriction")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, err := h.repo.GetSystemOptionByKey(r.Context(), "is_installed")
|
||||
if err != sql.ErrNoRows {
|
||||
log.WithError(err).Error("install handler called even though system is installed")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var requestData InstallRequestData
|
||||
err = json.NewDecoder(r.Body).Decode(&requestData)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
createdAt := time.Now().UTC()
|
||||
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(requestData.User.Password), 14)
|
||||
user, err := h.repo.CreateUserAccount(r.Context(), db.CreateUserAccountParams{
|
||||
FullName: requestData.User.FullName,
|
||||
Username: requestData.User.Username,
|
||||
Initials: requestData.User.Initials,
|
||||
Email: requestData.User.Email,
|
||||
PasswordHash: string(hashedPwd),
|
||||
CreatedAt: createdAt,
|
||||
RoleCode: "admin",
|
||||
})
|
||||
|
||||
_, err = h.repo.CreateSystemOption(r.Context(), db.CreateSystemOptionParams{Key: "is_installed", Value: sql.NullString{Valid: true, String: "true"}})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
refreshCreatedAt := time.Now().UTC()
|
||||
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
||||
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
|
||||
|
||||
log.WithField("userID", user.UserID.String()).Info("creating install access token")
|
||||
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.SecurityConfig.Secret, h.SecurityConfig.AccessTokenExpiration)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
log.Info(accessTokenString)
|
||||
|
||||
w.Header().Set("Content-type", "application/json")
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "refreshToken",
|
||||
Value: refreshTokenString.TokenID.String(),
|
||||
Expires: refreshExpiresAt,
|
||||
HttpOnly: true,
|
||||
})
|
||||
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
|
||||
json.NewEncoder(w).Encode(LoginResponseData{Complete: true, UserID: authToken.UserID.String()})
|
||||
}
|
||||
|
||||
func (h *TaskcafeHandler) ConfirmUser(w http.ResponseWriter, r *http.Request) {
|
||||
@ -382,23 +238,43 @@ func (h *TaskcafeHandler) ConfirmUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
}
|
||||
|
||||
refreshCreatedAt := time.Now().UTC()
|
||||
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
||||
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
|
||||
authCreatedAt := time.Now().UTC()
|
||||
authExpiresAt := authCreatedAt.AddDate(0, 0, 1)
|
||||
authToken, err := h.repo.CreateAuthToken(r.Context(), db.CreateAuthTokenParams{user.UserID, authCreatedAt, authExpiresAt})
|
||||
|
||||
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.SecurityConfig.Secret, h.SecurityConfig.AccessTokenExpiration)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-type", "application/json")
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "refreshToken",
|
||||
Value: refreshTokenString.TokenID.String(),
|
||||
Expires: refreshExpiresAt,
|
||||
Name: "authToken",
|
||||
Value: authToken.TokenID.String(),
|
||||
Path: "/",
|
||||
Expires: authExpiresAt,
|
||||
HttpOnly: true,
|
||||
})
|
||||
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
|
||||
json.NewEncoder(w).Encode(LoginResponseData{Complete: true, UserID: authToken.UserID.String()})
|
||||
}
|
||||
func (h *TaskcafeHandler) ValidateAuthTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("authToken")
|
||||
if err != nil {
|
||||
if err == http.ErrNoCookie {
|
||||
json.NewEncoder(w).Encode(ValidateAuthTokenResponse{Valid: false, UserID: ""})
|
||||
return
|
||||
}
|
||||
log.WithError(err).Error("unknown error")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
authTokenID := uuid.MustParse(c.Value)
|
||||
token, err := h.repo.GetAuthTokenByID(r.Context(), authTokenID)
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode(ValidateAuthTokenResponse{Valid: false, UserID: ""})
|
||||
} else {
|
||||
json.NewEncoder(w).Encode(ValidateAuthTokenResponse{Valid: true, UserID: token.UserID.String()})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *TaskcafeHandler) RegisterUser(w http.ResponseWriter, r *http.Request) {
|
||||
@ -425,6 +301,7 @@ func (h *TaskcafeHandler) RegisterUser(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error checking for active user")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !hasActiveUser {
|
||||
json.NewEncoder(w).Encode(RegisteredUserResponseData{Setup: true})
|
||||
@ -469,7 +346,7 @@ func (h *TaskcafeHandler) RegisterUser(w http.ResponseWriter, r *http.Request) {
|
||||
func (rs authResource) Routes(taskcafeHandler TaskcafeHandler) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/login", taskcafeHandler.LoginHandler)
|
||||
r.Post("/refresh_token", taskcafeHandler.RefreshTokenHandler)
|
||||
r.Post("/logout", taskcafeHandler.LogoutHandler)
|
||||
r.Post("/validate", taskcafeHandler.ValidateAuthTokenHandler)
|
||||
return r
|
||||
}
|
||||
|
@ -3,35 +3,51 @@ package route
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jordanknott/taskcafe/internal/auth"
|
||||
"github.com/jordanknott/taskcafe/internal/db"
|
||||
"github.com/jordanknott/taskcafe/internal/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// AuthenticationMiddleware is a middleware that requires a valid JWT token to be passed via the Authorization header
|
||||
type AuthenticationMiddleware struct {
|
||||
jwtKey []byte
|
||||
repo db.Repository
|
||||
}
|
||||
|
||||
// Middleware returns the middleware handler
|
||||
func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := uuid.New()
|
||||
bearerTokenRaw := r.Header.Get("Authorization")
|
||||
splitToken := strings.Split(bearerTokenRaw, "Bearer")
|
||||
if len(splitToken) != 2 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accessTokenString := strings.TrimSpace(splitToken[1])
|
||||
accessClaims, err := auth.ValidateAccessToken(accessTokenString, m.jwtKey)
|
||||
foundToken := true
|
||||
tokenRaw := ""
|
||||
c, err := r.Cookie("authToken")
|
||||
if err != nil {
|
||||
if _, ok := err.(*auth.ErrExpiredToken); ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{
|
||||
if err == http.ErrNoCookie {
|
||||
foundToken = false
|
||||
} else {
|
||||
log.WithError(err).Error("unknown error")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !foundToken {
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
log.WithError(err).Error("no auth token found in cookie or authorization header")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tokenRaw = token
|
||||
} else {
|
||||
tokenRaw = c.Value
|
||||
}
|
||||
authTokenID := uuid.MustParse(tokenRaw)
|
||||
token, err := m.repo.GetAuthTokenByID(r.Context(), authTokenID)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{
|
||||
"data": {},
|
||||
"errors": [
|
||||
{
|
||||
@ -41,27 +57,12 @@ func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
}
|
||||
]
|
||||
}`))
|
||||
return
|
||||
}
|
||||
log.Error(err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var userID uuid.UUID
|
||||
if accessClaims.Restricted == auth.InstallOnly {
|
||||
userID = uuid.New()
|
||||
} else {
|
||||
userID, err = uuid.Parse(accessClaims.UserID)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("middleware access token userID parse")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), utils.UserIDKey, userID)
|
||||
ctx = context.WithValue(ctx, utils.RestrictedModeKey, accessClaims.Restricted)
|
||||
ctx = context.WithValue(ctx, utils.OrgRoleKey, accessClaims.OrgRole)
|
||||
ctx := context.WithValue(r.Context(), utils.UserIDKey, token.UserID)
|
||||
// ctx = context.WithValue(ctx, utils.RestrictedModeKey, accessClaims.Restricted)
|
||||
// ctx = context.WithValue(ctx, utils.OrgRoleKey, accessClaims.OrgRole)
|
||||
ctx = context.WithValue(ctx, utils.ReqIDKey, requestID)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/jmoiron/sqlx"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
@ -80,6 +81,17 @@ func NewRouter(dbConnection *sqlx.DB, emailConfig utils.EmailConfig, securityCon
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Timeout(60 * time.Second))
|
||||
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
// AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts
|
||||
AllowedOrigins: []string{"https://*", "http://*"},
|
||||
// AllowOriginFunc: func(r *http.Request, origin string) bool { return true },
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Cookie", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300, // Maximum value not ignored by any of major browsers
|
||||
}))
|
||||
|
||||
repository := db.NewRepository(dbConnection)
|
||||
taskcafeHandler := TaskcafeHandler{*repository, securityConfig}
|
||||
|
||||
@ -91,7 +103,7 @@ func NewRouter(dbConnection *sqlx.DB, emailConfig utils.EmailConfig, securityCon
|
||||
mux.Post("/auth/confirm", taskcafeHandler.ConfirmUser)
|
||||
mux.Post("/auth/register", taskcafeHandler.RegisterUser)
|
||||
})
|
||||
auth := AuthenticationMiddleware{securityConfig.Secret}
|
||||
auth := AuthenticationMiddleware{*repository}
|
||||
r.Group(func(mux chi.Router) {
|
||||
mux.Use(auth.Middleware)
|
||||
mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload)
|
||||
|
Reference in New Issue
Block a user