7 Commits
0.2.0 ... 0.2.2

Author SHA1 Message Date
c7538a98e5 fix: segfault on database connection failure 2020-09-12 18:23:23 -05:00
fe84f97f18 fix: url encode avatar filename when showing path
fixes #61
2020-09-12 18:12:12 -05:00
52c60abcd7 fix: secret key is no longer hard coded
the secret key for signing JWT tokens is now read from server.secret.

if that does not exist, then a random UUID v4 is generated and used
instead. a log warning is also shown.
2020-09-12 18:03:17 -05:00
9fdb3008db docs(bug_report): add note about server logs 2020-09-12 03:33:24 -05:00
e2ef8a1a19 fix: initial access token after install is now set correctly 2020-09-12 03:24:09 -05:00
61cd376bfd fix: rename host to hostname in example config
fixes #59
2020-09-12 01:32:01 -05:00
ba9fc64fd9 fix: do not add localhost:3333 url to avatar urls
fixes #58
2020-09-12 01:23:48 -05:00
12 changed files with 69 additions and 35 deletions

View File

@ -18,6 +18,8 @@ If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.
Please send the Taskcafe web service logs if applicable.
<!--
Please read the contributing guide before working on any new pull requests!

View File

@ -1,5 +1,5 @@
[general]
host = '0.0.0.0:3333'
[server]
hostname = '0.0.0.0:3333'
[email_notifications]
enabled = true

View File

@ -59,7 +59,7 @@ const Install = () => {
} else {
const response: RefreshTokenResponse = await x.data;
const { accessToken: newToken, isInstalled } = response;
const claims: JWTToken = jwtDecode(accessToken);
const claims: JWTToken = jwtDecode(newToken);
const currentUser = {
id: claims.userId,
roles: {
@ -69,7 +69,7 @@ const Install = () => {
},
};
setUser(currentUser);
setAccessToken(accessToken);
setAccessToken(newToken);
if (!isInstalled) {
history.replace('/install');
}

View File

@ -8,6 +8,8 @@ import {
useCreateProjectMutation,
GetProjectsDocument,
GetProjectsQuery,
MeQuery,
MeDocument,
} from 'shared/generated/graphql';
import { Link } from 'react-router-dom';

View File

@ -1,6 +1,7 @@
let accessToken = '';
export function setAccessToken(newToken: string) {
console.log(newToken);
accessToken = newToken;
}
export function getAccessToken() {

View File

@ -7,8 +7,6 @@ import (
log "github.com/sirupsen/logrus"
)
var jwtKey = []byte("taskcafe_test_key")
// RestrictedMode is used restrict JWT access to just the install route
type RestrictedMode string
@ -54,7 +52,7 @@ func (r *ErrMalformedToken) Error() string {
}
// NewAccessToken generates a new JWT access token with the correct claims
func NewAccessToken(userID string, restrictedMode RestrictedMode, orgRole string) (string, error) {
func NewAccessToken(userID string, restrictedMode RestrictedMode, orgRole string, jwtKey []byte) (string, error) {
role := RoleMember
if orgRole == "admin" {
role = RoleAdmin
@ -76,7 +74,7 @@ func NewAccessToken(userID string, restrictedMode RestrictedMode, orgRole string
}
// NewAccessTokenCustomExpiration creates an access token with a custom duration
func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, error) {
func NewAccessTokenCustomExpiration(userID string, dur time.Duration, jwtKey []byte) (string, error) {
accessExpirationTime := time.Now().Add(dur)
accessClaims := &AccessTokenClaims{
UserID: userID,
@ -94,7 +92,7 @@ func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, e
}
// ValidateAccessToken validates a JWT access token and returns the contained claims or an error if it's invalid
func ValidateAccessToken(accessTokenString string) (AccessTokenClaims, error) {
func ValidateAccessToken(accessTokenString string, jwtKey []byte) (AccessTokenClaims, error) {
accessClaims := &AccessTokenClaims{}
accessToken, err := jwt.ParseWithClaims(accessTokenString, accessClaims, func(token *jwt.Token) (interface{}, error) {
return jwtKey, nil

View File

@ -1,12 +1,15 @@
package commands
import (
"errors"
"fmt"
"strings"
"time"
"github.com/jordanknott/taskcafe/internal/auth"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func newTokenCmd() *cobra.Command {
@ -15,13 +18,18 @@ func newTokenCmd() *cobra.Command {
Short: "Create a long lived JWT token for dev purposes",
Long: "Create a long lived JWT token for dev purposes",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
token, err := auth.NewAccessTokenCustomExpiration(args[0], time.Hour*24)
RunE: func(cmd *cobra.Command, args []string) error {
secret := viper.GetString("server.secret")
if strings.TrimSpace(secret) == "" {
return errors.New("server.secret must be set (TASKCAFE_SERVER_SECRET)")
}
token, err := auth.NewAccessTokenCustomExpiration(args[0], time.Hour*24, []byte(secret))
if err != nil {
log.WithError(err).Error("issue while creating access token")
return
return err
}
fmt.Println(token)
return nil
},
}
}

View File

@ -3,11 +3,13 @@ package commands
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/httpfs"
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@ -38,16 +40,21 @@ func newWebCmd() *cobra.Command {
)
var db *sqlx.DB
var err error
retryNumber := 0
for i := 0; retryNumber <= 3; i++ {
retryNumber++
var retryDuration time.Duration
maxRetryNumber := 4
for i := 0; i < maxRetryNumber; i++ {
db, err = sqlx.Connect("postgres", connection)
if err == nil {
break
}
retryDuration := time.Duration(i*2) * time.Second
log.WithFields(log.Fields{"retryNumber": retryNumber, "retryDuration": retryDuration}).WithError(err).Error("issue connecting to database, retrying")
time.Sleep(retryDuration)
retryDuration = time.Duration(i*2) * time.Second
log.WithFields(log.Fields{"retryNumber": i, "retryDuration": retryDuration}).WithError(err).Error("issue connecting to database, retrying")
if i != maxRetryNumber-1 {
time.Sleep(retryDuration)
}
}
if err != nil {
return err
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
@ -62,7 +69,12 @@ func newWebCmd() *cobra.Command {
}
log.WithFields(log.Fields{"url": viper.GetString("server.hostname")}).Info("starting server")
r, _ := route.NewRouter(db)
secret := viper.GetString("server.secret")
if strings.TrimSpace(secret) == "" {
log.Warn("server.secret is not set, generating a random secret")
secret = uuid.New().String()
}
r, _ := route.NewRouter(db, []byte(secret))
http.ListenAndServe(viper.GetString("server.hostname"), r)
return nil
},

View File

@ -14,8 +14,6 @@ import (
"golang.org/x/crypto/bcrypt"
)
var jwtKey = []byte("taskcafe_test_key")
type authResource struct{}
// LoginRequestData is the request data when a user logs in
@ -69,7 +67,7 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusInternalServerError)
return
}
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.InstallOnly, user.RoleCode)
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.InstallOnly, user.RoleCode, h.jwtKey)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
@ -123,7 +121,7 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusInternalServerError)
}
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted, user.RoleCode)
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
@ -190,7 +188,7 @@ func (h *TaskcafeHandler) 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(), auth.Unrestricted, user.RoleCode)
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
@ -251,10 +249,12 @@ func (h *TaskcafeHandler) InstallHandler(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(), auth.Unrestricted, user.RoleCode)
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{

View File

@ -5,7 +5,9 @@ import (
"encoding/json"
"io/ioutil"
"net/http"
"net/url"
"os"
"strings"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
@ -48,22 +50,24 @@ func (h *TaskcafeHandler) ProfileImageUpload(w http.ResponseWriter, r *http.Requ
return
}
defer file.Close()
log.WithFields(log.Fields{"filename": handler.Filename, "size": handler.Size, "header": handler.Header}).Info("file metadata")
filename := strings.ReplaceAll(handler.Filename, " ", "-")
encodedFilename := url.QueryEscape(filename)
log.WithFields(log.Fields{"filename": encodedFilename, "size": handler.Size, "header": handler.Header}).Info("file metadata")
fileBytes, err := ioutil.ReadAll(file)
if err != nil {
log.WithError(err).Error("while reading file")
return
}
err = ioutil.WriteFile("uploads/"+handler.Filename, fileBytes, 0644)
err = ioutil.WriteFile("uploads/"+filename, fileBytes, 0644)
if err != nil {
log.WithError(err).Error("while reading file")
return
}
h.repo.UpdateUserAccountProfileAvatarURL(r.Context(), db.UpdateUserAccountProfileAvatarURLParams{UserID: userID, ProfileAvatarUrl: sql.NullString{String: "http://localhost:3333/uploads/" + handler.Filename, Valid: true}})
h.repo.UpdateUserAccountProfileAvatarURL(r.Context(), db.UpdateUserAccountProfileAvatarURLParams{UserID: userID, ProfileAvatarUrl: sql.NullString{String: "/uploads/" + encodedFilename, Valid: true}})
// return that we have successfully uploaded our file!
log.Info("file uploaded")
json.NewEncoder(w).Encode(AvatarUploadResponseData{URL: "http://localhost:3333/uploads/" + handler.Filename, UserID: userID.String()})
json.NewEncoder(w).Encode(AvatarUploadResponseData{URL: "/uploads/" + encodedFilename, UserID: userID.String()})
}

View File

@ -12,7 +12,12 @@ import (
)
// AuthenticationMiddleware is a middleware that requires a valid JWT token to be passed via the Authorization header
func AuthenticationMiddleware(next http.Handler) http.Handler {
type AuthenticationMiddleware struct {
jwtKey []byte
}
// Middleware returns the middleware handler
func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bearerTokenRaw := r.Header.Get("Authorization")
splitToken := strings.Split(bearerTokenRaw, "Bearer")
@ -21,7 +26,7 @@ func AuthenticationMiddleware(next http.Handler) http.Handler {
return
}
accessTokenString := strings.TrimSpace(splitToken[1])
accessClaims, err := auth.ValidateAccessToken(accessTokenString)
accessClaims, err := auth.ValidateAccessToken(accessTokenString, m.jwtKey)
if err != nil {
if _, ok := err.(*auth.ErrExpiredToken); ok {
w.WriteHeader(http.StatusUnauthorized)

View File

@ -59,11 +59,12 @@ func (h FrontendHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// TaskcafeHandler contains all the route handlers
type TaskcafeHandler struct {
repo db.Repository
repo db.Repository
jwtKey []byte
}
// NewRouter creates a new router for chi
func NewRouter(dbConnection *sqlx.DB) (chi.Router, error) {
func NewRouter(dbConnection *sqlx.DB, jwtKey []byte) (chi.Router, error) {
formatter := new(log.TextFormatter)
formatter.TimestampFormat = "02-01-2006 15:04:05"
formatter.FullTimestamp = true
@ -79,7 +80,7 @@ func NewRouter(dbConnection *sqlx.DB) (chi.Router, error) {
r.Use(middleware.Timeout(60 * time.Second))
repository := db.NewRepository(dbConnection)
taskcafeHandler := TaskcafeHandler{*repository}
taskcafeHandler := TaskcafeHandler{*repository, jwtKey}
var imgServer = http.FileServer(http.Dir("./uploads/"))
r.Group(func(mux chi.Router) {
@ -88,8 +89,9 @@ func NewRouter(dbConnection *sqlx.DB) (chi.Router, error) {
mux.Mount("/uploads/", http.StripPrefix("/uploads/", imgServer))
})
auth := AuthenticationMiddleware{jwtKey}
r.Group(func(mux chi.Router) {
mux.Use(AuthenticationMiddleware)
mux.Use(auth.Middleware)
mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload)
mux.Post("/auth/install", taskcafeHandler.InstallHandler)
mux.Handle("/graphql", graph.NewHandler(*repository))