2020-07-05 01:02:57 +02:00
|
|
|
package route
|
2020-04-10 04:40:22 +02:00
|
|
|
|
|
|
|
import (
|
2020-07-17 02:40:23 +02:00
|
|
|
"database/sql"
|
2020-04-10 04:40:22 +02:00
|
|
|
"encoding/json"
|
|
|
|
"net/http"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/go-chi/chi"
|
|
|
|
"github.com/google/uuid"
|
2020-08-07 03:50:35 +02:00
|
|
|
"github.com/jordanknott/taskcafe/internal/db"
|
2020-04-10 04:40:22 +02:00
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
)
|
|
|
|
|
|
|
|
type authResource struct{}
|
|
|
|
|
2020-08-21 01:11:24 +02:00
|
|
|
// LoginRequestData is the request data when a user logs in
|
2020-07-17 02:40:23 +02:00
|
|
|
type LoginRequestData struct {
|
|
|
|
Username string
|
|
|
|
Password string
|
2020-07-05 01:02:57 +02:00
|
|
|
}
|
|
|
|
|
2020-08-21 01:11:24 +02:00
|
|
|
// NewUserAccount is the request data for a new user
|
2020-07-17 02:40:23 +02:00
|
|
|
type NewUserAccount struct {
|
|
|
|
FullName string `json:"fullname"`
|
2020-07-05 01:02:57 +02:00
|
|
|
Username string
|
|
|
|
Password string
|
2020-07-17 02:40:23 +02:00
|
|
|
Initials string
|
|
|
|
Email string
|
|
|
|
}
|
|
|
|
|
2020-10-21 01:52:09 +02:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2020-08-21 01:11:24 +02:00
|
|
|
// InstallRequestData is the request data for installing new Taskcafe app
|
2020-07-17 02:40:23 +02:00
|
|
|
type InstallRequestData struct {
|
|
|
|
User NewUserAccount
|
2020-07-05 01:02:57 +02:00
|
|
|
}
|
|
|
|
|
2020-10-21 01:52:09 +02:00
|
|
|
type Setup struct {
|
|
|
|
ConfirmToken string `json:"confirmToken"`
|
|
|
|
}
|
|
|
|
|
2021-04-29 04:32:19 +02:00
|
|
|
type ValidateAuthTokenResponse struct {
|
|
|
|
Valid bool `json:"valid"`
|
|
|
|
UserID string `json:"userID"`
|
|
|
|
}
|
|
|
|
|
2020-08-21 01:11:24 +02:00
|
|
|
// LoginResponseData is the response data for when a user logs in
|
2020-07-05 01:02:57 +02:00
|
|
|
type LoginResponseData struct {
|
2021-04-29 04:32:19 +02:00
|
|
|
UserID string `json:"userID"`
|
|
|
|
Complete bool `json:"complete"`
|
2020-07-05 01:02:57 +02:00
|
|
|
}
|
|
|
|
|
2020-08-21 01:11:24 +02:00
|
|
|
// LogoutResponseData is the response data for when a user logs out
|
2020-07-05 01:02:57 +02:00
|
|
|
type LogoutResponseData struct {
|
|
|
|
Status string `json:"status"`
|
|
|
|
}
|
|
|
|
|
2021-04-29 04:32:19 +02:00
|
|
|
// AuthTokenResponseData is the response data for when an access token is refreshed
|
|
|
|
type AuthTokenResponseData struct {
|
2020-07-05 01:02:57 +02:00
|
|
|
AccessToken string `json:"accessToken"`
|
|
|
|
}
|
|
|
|
|
2020-08-21 01:11:24 +02:00
|
|
|
// AvatarUploadResponseData is the response data for a user profile is uploaded
|
2020-07-05 01:02:57 +02:00
|
|
|
type AvatarUploadResponseData struct {
|
|
|
|
UserID string `json:"userID"`
|
|
|
|
URL string `json:"url"`
|
|
|
|
}
|
|
|
|
|
2020-08-21 01:11:24 +02:00
|
|
|
// LogoutHandler removes all refresh tokens to log out user
|
2020-08-07 03:50:35 +02:00
|
|
|
func (h *TaskcafeHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
2021-04-29 04:32:19 +02:00
|
|
|
c, err := r.Cookie("authToken")
|
2020-04-20 05:02:55 +02:00
|
|
|
if err != nil {
|
|
|
|
if err == http.ErrNoCookie {
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
refreshTokenID := uuid.MustParse(c.Value)
|
2021-04-29 04:32:19 +02:00
|
|
|
err = h.repo.DeleteAuthTokenByID(r.Context(), refreshTokenID)
|
2020-04-20 05:02:55 +02:00
|
|
|
if err != nil {
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(LogoutResponseData{Status: "success"})
|
|
|
|
}
|
|
|
|
|
2020-08-21 01:11:24 +02:00
|
|
|
// LoginHandler creates a new refresh & access token for the user if given the correct credentials
|
2020-08-07 03:50:35 +02:00
|
|
|
func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
2021-10-18 00:12:13 +02:00
|
|
|
if h.SecurityConfig.IsRemoteAuth() {
|
|
|
|
h.headerAuthenticate(w, r)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
h.credentialsHandler(w, r)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (h *TaskcafeHandler) credentialsHandler(w http.ResponseWriter, r *http.Request) {
|
2020-04-10 04:40:22 +02:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2020-10-21 01:52:09 +02:00
|
|
|
if !user.Active {
|
|
|
|
log.WithFields(log.Fields{
|
|
|
|
"username": requestData.Username,
|
|
|
|
}).Warn("attempt to login with inactive user")
|
|
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-04-10 04:40:22 +02:00
|
|
|
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(requestData.Password))
|
|
|
|
if err != nil {
|
|
|
|
log.WithFields(log.Fields{
|
2020-08-21 01:11:24 +02:00
|
|
|
"username": requestData.Username,
|
|
|
|
}).Warn("password incorrect for user")
|
2020-04-10 04:40:22 +02:00
|
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2021-04-29 04:32:19 +02:00
|
|
|
authCreatedAt := time.Now().UTC()
|
|
|
|
authExpiresAt := authCreatedAt.AddDate(0, 0, 1)
|
|
|
|
authToken, err := h.repo.CreateAuthToken(r.Context(), db.CreateAuthTokenParams{user.UserID, authCreatedAt, authExpiresAt})
|
2021-10-18 00:12:13 +02:00
|
|
|
if err != nil {
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
// TODO: should we return here?
|
|
|
|
}
|
|
|
|
|
|
|
|
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()})
|
|
|
|
}
|
2020-07-17 02:40:23 +02:00
|
|
|
|
2021-10-18 00:12:13 +02:00
|
|
|
func (h *TaskcafeHandler) headerAuthenticate(w http.ResponseWriter, r *http.Request) {
|
|
|
|
xRemoteUser := r.Header.Get(h.SecurityConfig.UserAuthHeader)
|
|
|
|
user, err := h.repo.GetUserAccountByUsername(r.Context(), xRemoteUser)
|
|
|
|
if err != nil {
|
|
|
|
log.WithFields(log.Fields{
|
|
|
|
"username": xRemoteUser,
|
|
|
|
}).Warn("user account not found")
|
|
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if !user.Active {
|
|
|
|
log.WithFields(log.Fields{
|
|
|
|
"username": user.Username,
|
|
|
|
}).Warn("attempt to login with inactive 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})
|
2020-04-10 04:40:22 +02:00
|
|
|
if err != nil {
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
2021-10-18 00:12:13 +02:00
|
|
|
return
|
2020-04-10 04:40:22 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
w.Header().Set("Content-type", "application/json")
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
2021-04-29 04:32:19 +02:00
|
|
|
Name: "authToken",
|
|
|
|
Value: authToken.TokenID.String(),
|
|
|
|
Expires: authExpiresAt,
|
|
|
|
Path: "/",
|
2020-04-10 04:40:22 +02:00
|
|
|
HttpOnly: true,
|
|
|
|
})
|
2021-04-29 04:32:19 +02:00
|
|
|
json.NewEncoder(w).Encode(LoginResponseData{Complete: true, UserID: authToken.UserID.String()})
|
2020-04-10 04:40:22 +02:00
|
|
|
}
|
|
|
|
|
2020-10-21 01:52:09 +02:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
2021-04-29 04:32:19 +02:00
|
|
|
authCreatedAt := time.Now().UTC()
|
|
|
|
authExpiresAt := authCreatedAt.AddDate(0, 0, 1)
|
|
|
|
authToken, err := h.repo.CreateAuthToken(r.Context(), db.CreateAuthTokenParams{user.UserID, authCreatedAt, authExpiresAt})
|
2020-10-21 01:52:09 +02:00
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
2021-04-29 04:32:19 +02:00
|
|
|
return
|
2020-10-21 01:52:09 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
w.Header().Set("Content-type", "application/json")
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
2021-04-29 04:32:19 +02:00
|
|
|
Name: "authToken",
|
|
|
|
Value: authToken.TokenID.String(),
|
|
|
|
Path: "/",
|
|
|
|
Expires: authExpiresAt,
|
2020-10-21 01:52:09 +02:00
|
|
|
HttpOnly: true,
|
|
|
|
})
|
2021-04-29 04:32:19 +02:00
|
|
|
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()})
|
|
|
|
}
|
2020-10-21 01:52:09 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
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)
|
2021-04-29 04:32:19 +02:00
|
|
|
return
|
2020-10-21 01:52:09 +02:00
|
|
|
}
|
|
|
|
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 {
|
2021-09-13 18:22:48 +02:00
|
|
|
log.WithError(err).Error("issue registering user account")
|
2020-10-21 01:52:09 +02:00
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
log.WithField("username", user.UserID).Info("registered new user account")
|
|
|
|
json.NewEncoder(w).Encode(RegisteredUserResponseData{Setup: !userExists})
|
|
|
|
}
|
|
|
|
|
2020-08-21 01:11:24 +02:00
|
|
|
// Routes registers all authentication routes
|
2020-08-07 03:50:35 +02:00
|
|
|
func (rs authResource) Routes(taskcafeHandler TaskcafeHandler) chi.Router {
|
2020-04-10 04:40:22 +02:00
|
|
|
r := chi.NewRouter()
|
2020-08-07 03:50:35 +02:00
|
|
|
r.Post("/login", taskcafeHandler.LoginHandler)
|
|
|
|
r.Post("/logout", taskcafeHandler.LogoutHandler)
|
2021-04-29 04:32:19 +02:00
|
|
|
r.Post("/validate", taskcafeHandler.ValidateAuthTokenHandler)
|
2020-04-10 04:40:22 +02:00
|
|
|
return r
|
|
|
|
}
|