taskcafe/internal/route/auth.go
Jordan Knott 7b6624ecc3 feat: redesign project sharing & initial registration
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').
2020-12-17 22:39:14 -06:00

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
}