7b6624ecc3
redesigned the project sharing popup to be a multi select dropdown that populates the options by using the input as a fuzzy search filter on the current users & invited users. users can now also be directly invited by email from the project share window. if invited this way, then the user will receive an email that sends them to a registration page, then a confirmation page. the initial registration was always redone so that it uses a similar system to the above in that it now will accept the first registered user if there are no other accounts (besides 'system').
476 lines
14 KiB
Go
476 lines
14 KiB
Go
package route
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
type authResource struct{}
|
|
|
|
// LoginRequestData is the request data when a user logs in
|
|
type LoginRequestData struct {
|
|
Username string
|
|
Password string
|
|
}
|
|
|
|
// NewUserAccount is the request data for a new user
|
|
type NewUserAccount struct {
|
|
FullName string `json:"fullname"`
|
|
Username string
|
|
Password string
|
|
Initials string
|
|
Email string
|
|
}
|
|
|
|
// RegisterUserRequestData is the request data for registering a new user (duh)
|
|
type RegisterUserRequestData struct {
|
|
User NewUserAccount
|
|
}
|
|
|
|
type RegisteredUserResponseData struct {
|
|
Setup bool `json:"setup"`
|
|
}
|
|
|
|
// ConfirmUserRequestData is the request data for upgrading an invited user to a normal user
|
|
type ConfirmUserRequestData struct {
|
|
ConfirmToken string
|
|
}
|
|
|
|
// InstallRequestData is the request data for installing new Taskcafe app
|
|
type InstallRequestData struct {
|
|
User NewUserAccount
|
|
}
|
|
|
|
type Setup struct {
|
|
ConfirmToken string `json:"confirmToken"`
|
|
}
|
|
|
|
// LoginResponseData is the response data for when a user logs in
|
|
type LoginResponseData struct {
|
|
AccessToken string `json:"accessToken"`
|
|
Setup bool `json:"setup"`
|
|
}
|
|
|
|
// LogoutResponseData is the response data for when a user logs out
|
|
type LogoutResponseData struct {
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// RefreshTokenResponseData is the response data for when an access token is refreshed
|
|
type RefreshTokenResponseData struct {
|
|
AccessToken string `json:"accessToken"`
|
|
}
|
|
|
|
// AvatarUploadResponseData is the response data for a user profile is uploaded
|
|
type AvatarUploadResponseData struct {
|
|
UserID string `json:"userID"`
|
|
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.jwtKey)
|
|
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")
|
|
if err != nil {
|
|
if err == http.ErrNoCookie {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
refreshTokenID := uuid.MustParse(c.Value)
|
|
err = h.repo.DeleteRefreshTokenByID(r.Context(), refreshTokenID)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(LogoutResponseData{Status: "success"})
|
|
}
|
|
|
|
// LoginHandler creates a new refresh & access token for the user if given the correct credentials
|
|
func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
|
var requestData LoginRequestData
|
|
err := json.NewDecoder(r.Body).Decode(&requestData)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
log.Debug("bad request body")
|
|
return
|
|
}
|
|
|
|
user, err := h.repo.GetUserAccountByUsername(r.Context(), requestData.Username)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"username": requestData.Username,
|
|
}).Warn("user account not found")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if !user.Active {
|
|
log.WithFields(log.Fields{
|
|
"username": requestData.Username,
|
|
}).Warn("attempt to login with inactive user")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(requestData.Password))
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"username": requestData.Username,
|
|
}).Warn("password incorrect for 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{user.UserID, refreshCreatedAt, refreshExpiresAt})
|
|
|
|
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
|
|
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,
|
|
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.jwtKey)
|
|
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})
|
|
}
|
|
|
|
func (h *TaskcafeHandler) ConfirmUser(w http.ResponseWriter, r *http.Request) {
|
|
usersExist, err := h.repo.HasActiveUser(r.Context())
|
|
if err != nil {
|
|
log.WithError(err).Error("issue checking if user accounts exist")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var user db.UserAccount
|
|
if !usersExist {
|
|
log.Info("setting first inactive user to active")
|
|
user, err = h.repo.SetFirstUserActive(r.Context())
|
|
if err != nil {
|
|
log.WithError(err).Error("issue checking if user accounts exist")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
} else {
|
|
var requestData ConfirmUserRequestData
|
|
err = json.NewDecoder(r.Body).Decode(&requestData)
|
|
if err != nil {
|
|
log.WithError(err).Error("issue decoding request data")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
confirmTokenID, err := uuid.Parse(requestData.ConfirmToken)
|
|
if err != nil {
|
|
log.WithError(err).Error("issue parsing confirm token")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
confirmToken, err := h.repo.GetConfirmTokenByID(r.Context(), confirmTokenID)
|
|
if err != nil {
|
|
log.WithError(err).Error("issue getting token by id")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
user, err = h.repo.SetUserActiveByEmail(r.Context(), confirmToken.Email)
|
|
if err != nil {
|
|
log.WithError(err).Error("issue getting account by email")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
projects, err := h.repo.GetProjectsForInvitedMember(r.Context(), user.Email)
|
|
for _, project := range projects {
|
|
member, err := h.repo.CreateProjectMember(r.Context(),
|
|
db.CreateProjectMemberParams{
|
|
ProjectID: project,
|
|
UserID: user.UserID,
|
|
AddedAt: now,
|
|
RoleCode: "member",
|
|
},
|
|
)
|
|
if err != nil {
|
|
log.WithError(err).Error("issue creating project member")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
log.WithField("memberID", member.ProjectMemberID).Info("creating project member")
|
|
err = h.repo.DeleteProjectMemberInvitedForEmail(r.Context(), user.Email)
|
|
if err != nil {
|
|
log.WithError(err).Error("issue deleting project member invited")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
err = h.repo.DeleteUserAccountInvitedForEmail(r.Context(), user.Email)
|
|
if err != nil {
|
|
log.WithError(err).Error("issue deleting user account invited")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
err = h.repo.DeleteConfirmTokenForEmail(r.Context(), user.Email)
|
|
if err != nil {
|
|
log.WithError(err).Error("issue deleting confirm token")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
}
|
|
|
|
refreshCreatedAt := time.Now().UTC()
|
|
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
|
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
|
|
|
|
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
|
|
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,
|
|
HttpOnly: true,
|
|
})
|
|
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
|
|
}
|
|
|
|
func (h *TaskcafeHandler) RegisterUser(w http.ResponseWriter, r *http.Request) {
|
|
userExists, err := h.repo.HasAnyUser(r.Context())
|
|
if err != nil {
|
|
log.WithError(err).Error("issue checking if user accounts exist")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var requestData RegisterUserRequestData
|
|
err = json.NewDecoder(r.Body).Decode(&requestData)
|
|
if err != nil {
|
|
log.WithError(err).Error("issue decoding register user request data")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if userExists {
|
|
_, err := h.repo.GetInvitedUserByEmail(r.Context(), requestData.User.Email)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
hasActiveUser, err := h.repo.HasActiveUser(r.Context())
|
|
if err != nil {
|
|
log.WithError(err).Error("error checking for active user")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
if !hasActiveUser {
|
|
json.NewEncoder(w).Encode(RegisteredUserResponseData{Setup: true})
|
|
return
|
|
}
|
|
} else {
|
|
log.WithError(err).Error("error while retrieving invited user by email")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
// TODO: accept user if public registration is enabled
|
|
|
|
createdAt := time.Now().UTC()
|
|
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(requestData.User.Password), 14)
|
|
if err != nil {
|
|
log.Error("issue generating passoed")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
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",
|
|
Active: false,
|
|
})
|
|
if err != nil {
|
|
log.Error("issue registering user account")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
log.WithField("username", user.UserID).Info("registered new user account")
|
|
json.NewEncoder(w).Encode(RegisteredUserResponseData{Setup: !userExists})
|
|
}
|
|
|
|
// Routes registers all authentication routes
|
|
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)
|
|
return r
|
|
}
|