feature: add first time install process
This commit is contained in:
@ -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()},
|
||||
}
|
||||
|
||||
|
@ -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"`
|
||||
|
@ -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)
|
||||
|
5
internal/db/query/system_options.sql
Normal file
5
internal/db/query/system_options.sql
Normal 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 *;
|
41
internal/db/system_options.sql.go
Normal file
41
internal/db/system_options.sql.go
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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))
|
||||
})
|
||||
|
@ -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))
|
||||
})
|
||||
|
||||
|
Reference in New Issue
Block a user