feature: add first time install process

This commit is contained in:
Jordan Knott
2020-07-16 19:40:23 -05:00
parent 90515f6aa4
commit 2cf6be082c
42 changed files with 1834 additions and 1053 deletions

View File

@ -9,8 +9,16 @@ import (
var jwtKey = []byte("citadel_test_key")
type RestrictedMode string
const (
Unrestricted RestrictedMode = "unrestricted"
InstallOnly = "install_only"
)
type AccessTokenClaims struct {
UserID string `json:"userId"`
UserID string `json:"userId"`
Restricted RestrictedMode `json:"restricted"`
jwt.StandardClaims
}
@ -31,10 +39,11 @@ func (r *ErrMalformedToken) Error() string {
return "token is malformed"
}
func NewAccessToken(userID string) (string, error) {
func NewAccessToken(userID string, restrictedMode RestrictedMode) (string, error) {
accessExpirationTime := time.Now().Add(5 * time.Second)
accessClaims := &AccessTokenClaims{
UserID: userID,
Restricted: restrictedMode,
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
}
@ -50,6 +59,7 @@ func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, e
accessExpirationTime := time.Now().Add(dur)
accessClaims := &AccessTokenClaims{
UserID: userID,
Restricted: Unrestricted,
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
}

View File

@ -58,6 +58,12 @@ type Role struct {
Name string `json:"name"`
}
type SystemOption struct {
OptionID uuid.UUID `json:"option_id"`
Key string `json:"key"`
Value sql.NullString `json:"value"`
}
type Task struct {
TaskID uuid.UUID `json:"task_id"`
TaskGroupID uuid.UUID `json:"task_group_id"`

View File

@ -15,6 +15,7 @@ type Querier interface {
CreateProjectLabel(ctx context.Context, arg CreateProjectLabelParams) (ProjectLabel, error)
CreateProjectMember(ctx context.Context, arg CreateProjectMemberParams) (ProjectMember, error)
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
CreateSystemOption(ctx context.Context, arg CreateSystemOptionParams) (SystemOption, error)
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
CreateTaskAssigned(ctx context.Context, arg CreateTaskAssignedParams) (TaskAssigned, error)
CreateTaskChecklist(ctx context.Context, arg CreateTaskChecklistParams) (TaskChecklist, error)
@ -61,6 +62,7 @@ type Querier interface {
GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRoleForProjectMemberByUserIDParams) (Role, error)
GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error)
GetRoleForUserID(ctx context.Context, userID uuid.UUID) (GetRoleForUserIDRow, error)
GetSystemOptionByKey(ctx context.Context, key string) (GetSystemOptionByKeyRow, error)
GetTaskByID(ctx context.Context, taskID uuid.UUID) (Task, error)
GetTaskChecklistByID(ctx context.Context, taskChecklistID uuid.UUID) (TaskChecklist, error)
GetTaskChecklistItemByID(ctx context.Context, taskChecklistItemID uuid.UUID) (TaskChecklistItem, error)

View File

@ -0,0 +1,5 @@
-- name: GetSystemOptionByKey :one
SELECT key, value FROM system_options WHERE key = $1;
-- name: CreateSystemOption :one
INSERT INTO system_options (key, value) VALUES ($1, $2) RETURNING *;

View File

@ -0,0 +1,41 @@
// Code generated by sqlc. DO NOT EDIT.
// source: system_options.sql
package db
import (
"context"
"database/sql"
)
const createSystemOption = `-- name: CreateSystemOption :one
INSERT INTO system_options (key, value) VALUES ($1, $2) RETURNING option_id, key, value
`
type CreateSystemOptionParams struct {
Key string `json:"key"`
Value sql.NullString `json:"value"`
}
func (q *Queries) CreateSystemOption(ctx context.Context, arg CreateSystemOptionParams) (SystemOption, error) {
row := q.db.QueryRowContext(ctx, createSystemOption, arg.Key, arg.Value)
var i SystemOption
err := row.Scan(&i.OptionID, &i.Key, &i.Value)
return i, err
}
const getSystemOptionByKey = `-- name: GetSystemOptionByKey :one
SELECT key, value FROM system_options WHERE key = $1
`
type GetSystemOptionByKeyRow struct {
Key string `json:"key"`
Value sql.NullString `json:"value"`
}
func (q *Queries) GetSystemOptionByKey(ctx context.Context, key string) (GetSystemOptionByKeyRow, error) {
row := q.db.QueryRowContext(ctx, getSystemOptionByKey, key)
var i GetSystemOptionByKeyRow
err := row.Scan(&i.Key, &i.Value)
return i, err
}

View File

@ -12,6 +12,7 @@ import (
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/google/uuid"
"github.com/jordanknott/project-citadel/api/internal/auth"
"github.com/jordanknott/project-citadel/api/internal/config"
"github.com/jordanknott/project-citadel/api/internal/db"
)
@ -49,7 +50,13 @@ func NewHandler(config config.AppConfig, repo db.Repository) http.Handler {
func NewPlaygroundHandler(endpoint string) http.Handler {
return playground.Handler("GraphQL Playground", endpoint)
}
func GetUserID(ctx context.Context) (uuid.UUID, bool) {
userID, ok := ctx.Value("userID").(uuid.UUID)
return userID, ok
}
func GetRestrictedMode(ctx context.Context) (auth.RestrictedMode, bool) {
restricted, ok := ctx.Value("restricted_mode").(auth.RestrictedMode)
return restricted, ok
}

View File

@ -706,6 +706,7 @@ func (r *mutationResolver) DeleteTeamMember(ctx context.Context, input DeleteTea
if err != nil {
return &DeleteTeamMemberPayload{}, err
}
_, err = r.Repository.GetTeamMemberByID(ctx, db.GetTeamMemberByIDParams{TeamID: input.TeamID, UserID: input.UserID})
if err != nil {
return &DeleteTeamMemberPayload{}, err
@ -992,7 +993,10 @@ func (r *queryResolver) Me(ctx context.Context) (*db.UserAccount, error) {
return &db.UserAccount{}, fmt.Errorf("internal server error")
}
user, err := r.Repository.GetUserAccountByID(ctx, userID)
if err != nil {
if err == sql.ErrNoRows {
log.WithFields(log.Fields{"userID": userID}).Warning("can not find user for me query")
return &db.UserAccount{}, nil
} else if err != nil {
return &db.UserAccount{}, err
}
return &user, err

View File

@ -1,11 +1,11 @@
package route
import (
"database/sql"
"encoding/json"
"net/http"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/go-chi/chi"
"github.com/google/uuid"
"github.com/jordanknott/project-citadel/api/internal/auth"
@ -18,23 +18,26 @@ var jwtKey = []byte("citadel_test_key")
type authResource struct{}
type AccessTokenClaims struct {
UserID string `json:"userId"`
jwt.StandardClaims
}
type RefreshTokenClaims struct {
UserID string `json:"userId"`
jwt.StandardClaims
}
type LoginRequestData struct {
Username string
Password string
}
type NewUserAccount struct {
FullName string `json:"fullname"`
Username string
Password string
Initials string
Email string
}
type InstallRequestData struct {
User NewUserAccount
}
type LoginResponseData struct {
AccessToken string `json:"accessToken"`
IsInstalled bool `json:"isInstalled"`
}
type LogoutResponseData struct {
@ -51,18 +54,48 @@ type AvatarUploadResponseData struct {
}
func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
_, err := h.repo.GetSystemOptionByKey(r.Context(), "is_installed")
if err == sql.ErrNoRows {
user, err := h.repo.GetUserAccountByUsername(r.Context(), "system")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.InstallOnly)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
w.Header().Set("Content-type", "application/json")
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, IsInstalled: false})
return
} else if err != nil {
log.WithError(err).Error("get system option")
w.WriteHeader(http.StatusBadRequest)
return
}
c, err := r.Cookie("refreshToken")
if err != nil {
if err == http.ErrNoCookie {
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
}
@ -76,7 +109,7 @@ func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Requ
w.WriteHeader(http.StatusInternalServerError)
}
accessTokenString, err := auth.NewAccessToken(token.UserID.String())
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
@ -88,7 +121,7 @@ func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Requ
Expires: refreshExpiresAt,
HttpOnly: true,
})
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString})
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, IsInstalled: true})
}
func (h *CitadelHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
@ -142,7 +175,7 @@ func (h *CitadelHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
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())
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
@ -154,7 +187,68 @@ func (h *CitadelHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
Expires: refreshExpiresAt,
HttpOnly: true,
})
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString})
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
}
func (h *CitadelHandler) 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
}
log.WithFields(log.Fields{"r": requestData}).Info("install")
createdAt := time.Now().UTC()
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(requestData.User.Password), 14)
user, err := h.repo.CreateUserAccount(r.Context(), db.CreateUserAccountParams{
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})
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted)
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 (rs authResource) Routes(citadelHandler CitadelHandler) chi.Router {

View File

@ -40,13 +40,19 @@ func AuthenticationMiddleware(next http.Handler) http.Handler {
return
}
userID, err := uuid.Parse(accessClaims.UserID)
if err != nil {
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(), "userID", userID)
ctx = context.WithValue(ctx, "restricted_mode", accessClaims.Restricted)
next.ServeHTTP(w, r.WithContext(ctx))
})

View File

@ -104,6 +104,7 @@ func NewRouter(config config.AppConfig, dbConnection *sqlx.DB) (chi.Router, erro
r.Group(func(mux chi.Router) {
mux.Use(AuthenticationMiddleware)
mux.Post("/users/me/avatar", citadelHandler.ProfileImageUpload)
mux.Post("/auth/install", citadelHandler.InstallHandler)
mux.Handle("/graphql", graph.NewHandler(config, *repository))
})