package route import ( "database/sql" "encoding/json" "net/http" "time" "github.com/go-chi/chi" "github.com/google/uuid" "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"` } 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 { UserID string `json:"userID"` Complete bool `json:"complete"` } // LogoutResponseData is the response data for when a user logs out type LogoutResponseData struct { Status string `json:"status"` } // AuthTokenResponseData is the response data for when an access token is refreshed type AuthTokenResponseData 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"` } // LogoutHandler removes all refresh tokens to log out user func (h *TaskcafeHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) { c, err := r.Cookie("authToken") 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.DeleteAuthTokenByID(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 } authCreatedAt := time.Now().UTC() authExpiresAt := authCreatedAt.AddDate(0, 0, 1) authToken, err := h.repo.CreateAuthToken(r.Context(), db.CreateAuthTokenParams{user.UserID, authCreatedAt, authExpiresAt}) if err != nil { w.WriteHeader(http.StatusInternalServerError) } w.Header().Set("Content-type", "application/json") http.SetCookie(w, &http.Cookie{ Name: "authToken", Value: authToken.TokenID.String(), Expires: authExpiresAt, Path: "/", HttpOnly: true, }) json.NewEncoder(w).Encode(LoginResponseData{Complete: true, UserID: authToken.UserID.String()}) } 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 } } authCreatedAt := time.Now().UTC() authExpiresAt := authCreatedAt.AddDate(0, 0, 1) authToken, err := h.repo.CreateAuthToken(r.Context(), db.CreateAuthTokenParams{user.UserID, authCreatedAt, authExpiresAt}) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-type", "application/json") http.SetCookie(w, &http.Cookie{ Name: "authToken", Value: authToken.TokenID.String(), Path: "/", Expires: authExpiresAt, HttpOnly: true, }) 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) { 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) return } 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("/logout", taskcafeHandler.LogoutHandler) r.Post("/validate", taskcafeHandler.ValidateAuthTokenHandler) return r }