[feat] Add header authentication support

In some cases there are needs to authenticate user not in Taskcafe itself.
For this reason option server.remote_user_header was added.

```toml
[server]
remote_user_header = "X-Remote-User"
```

With turned on Taskcafe listens X-Remote-User http header and skip
password checking. But still check user existence and activity flag.
This commit is contained in:
Maxim Kernozhitskiy 2021-10-18 01:12:13 +03:00
parent d725e42adf
commit c12a745929
8 changed files with 90 additions and 13 deletions

View File

@ -21,4 +21,4 @@ windows:
- database: - database:
root: ./ root: ./
panes: panes:
- pgcli postgres://taskcafe:taskcafe_test@localhost:8855/taskcafe - pgcli postgres://taskcafe:taskcafe_test@localhost:8865/taskcafe

View File

@ -1,5 +1,6 @@
[server] [server]
hostname = '0.0.0.0:3333' hostname = '0.0.0.0:3333'
remote_user_header = ""
[email_notifications] [email_notifications]
enabled = true enabled = true

9
docs/remote-auth.md Normal file
View File

@ -0,0 +1,9 @@
# Remote authorize
If you need to authenticate user with some proxy, you should use
```toml
[server]
remote_user_header = "X-Remote-User"
```
With this option Taskcafe will take username from
`X-Remote-User` HTTP header and will not check its password.
You can use any header you want.

View File

@ -81,6 +81,7 @@ func Execute() {
viper.SetDefault("database.password", "taskcafe_test") viper.SetDefault("database.password", "taskcafe_test")
viper.SetDefault("database.port", "5432") viper.SetDefault("database.port", "5432")
viper.SetDefault("security.token_expiration", "15m") viper.SetDefault("security.token_expiration", "15m")
viper.SetDefault("server.remote_user_header", "")
viper.SetDefault("queue.broker", "amqp://guest:guest@localhost:5672/") viper.SetDefault("queue.broker", "amqp://guest:guest@localhost:5672/")
viper.SetDefault("queue.store", "memcache://localhost:11211") viper.SetDefault("queue.store", "memcache://localhost:11211")

View File

@ -75,15 +75,24 @@ func newWebCmd() *cobra.Command {
log.Warn("server.secret is not set, generating a random secret") log.Warn("server.secret is not set, generating a random secret")
secret = uuid.New().String() secret = uuid.New().String()
} }
security, err := utils.GetSecurityConfig(viper.GetString("security.token_expiration"), []byte(secret)) security, err := utils.GetSecurityConfig(viper.GetString("security.token_expiration"), []byte(secret))
r, _ := route.NewRouter(db, utils.EmailConfig{ if err != nil {
From: viper.GetString("smtp.from"), log.Error(err)
Host: viper.GetString("smtp.host"), }
Port: viper.GetInt("smtp.port"), security.UserAuthHeader = viper.GetString("server.remote_user_header")
Username: viper.GetString("smtp.username"),
Password: viper.GetString("smtp.password"), r, _ := route.NewRouter(db, route.Config{
InsecureSkipVerify: viper.GetBool("smtp.skip_verify"), Email: utils.EmailConfig{
}, security) From: viper.GetString("smtp.from"),
Host: viper.GetString("smtp.host"),
Port: viper.GetInt("smtp.port"),
Username: viper.GetString("smtp.username"),
Password: viper.GetString("smtp.password"),
InsecureSkipVerify: viper.GetBool("smtp.skip_verify"),
},
Security: security,
})
log.WithFields(log.Fields{"url": viper.GetString("server.hostname")}).Info("starting server") log.WithFields(log.Fields{"url": viper.GetString("server.hostname")}).Info("starting server")
return http.ListenAndServe(viper.GetString("server.hostname"), r) return http.ListenAndServe(viper.GetString("server.hostname"), r)
}, },

View File

@ -102,6 +102,15 @@ func (h *TaskcafeHandler) LogoutHandler(w http.ResponseWriter, r *http.Request)
// LoginHandler creates a new refresh & access token for the user if given the correct credentials // 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) { func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
if h.SecurityConfig.IsRemoteAuth() {
h.headerAuthenticate(w, r)
return
}
h.credentialsHandler(w, r)
}
func (h *TaskcafeHandler) credentialsHandler(w http.ResponseWriter, r *http.Request) {
var requestData LoginRequestData var requestData LoginRequestData
err := json.NewDecoder(r.Body).Decode(&requestData) err := json.NewDecoder(r.Body).Decode(&requestData)
if err != nil { if err != nil {
@ -139,9 +148,47 @@ func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
authCreatedAt := time.Now().UTC() authCreatedAt := time.Now().UTC()
authExpiresAt := authCreatedAt.AddDate(0, 0, 1) authExpiresAt := authCreatedAt.AddDate(0, 0, 1)
authToken, err := h.repo.CreateAuthToken(r.Context(), db.CreateAuthTokenParams{user.UserID, authCreatedAt, authExpiresAt}) authToken, err := h.repo.CreateAuthToken(r.Context(), db.CreateAuthTokenParams{user.UserID, authCreatedAt, authExpiresAt})
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) 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()})
}
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})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
} }
w.Header().Set("Content-type", "application/json") w.Header().Set("Content-type", "application/json")

View File

@ -65,8 +65,13 @@ type TaskcafeHandler struct {
SecurityConfig utils.SecurityConfig SecurityConfig utils.SecurityConfig
} }
type Config struct {
Email utils.EmailConfig
Security utils.SecurityConfig
}
// NewRouter creates a new router for chi // NewRouter creates a new router for chi
func NewRouter(dbConnection *sqlx.DB, emailConfig utils.EmailConfig, securityConfig utils.SecurityConfig) (chi.Router, error) { func NewRouter(dbConnection *sqlx.DB, cfg Config) (chi.Router, error) {
formatter := new(log.TextFormatter) formatter := new(log.TextFormatter)
formatter.TimestampFormat = "02-01-2006 15:04:05" formatter.TimestampFormat = "02-01-2006 15:04:05"
formatter.FullTimestamp = true formatter.FullTimestamp = true
@ -93,7 +98,7 @@ func NewRouter(dbConnection *sqlx.DB, emailConfig utils.EmailConfig, securityCon
})) }))
repository := db.NewRepository(dbConnection) repository := db.NewRepository(dbConnection)
taskcafeHandler := TaskcafeHandler{*repository, securityConfig} taskcafeHandler := TaskcafeHandler{*repository, cfg.Security}
var imgServer = http.FileServer(http.Dir("./uploads/")) var imgServer = http.FileServer(http.Dir("./uploads/"))
r.Group(func(mux chi.Router) { r.Group(func(mux chi.Router) {
@ -108,7 +113,7 @@ func NewRouter(dbConnection *sqlx.DB, emailConfig utils.EmailConfig, securityCon
r.Group(func(mux chi.Router) { r.Group(func(mux chi.Router) {
mux.Use(auth.Middleware) mux.Use(auth.Middleware)
mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload) mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload)
mux.Handle("/graphql", graph.NewHandler(*repository, emailConfig)) mux.Handle("/graphql", graph.NewHandler(*repository, cfg.Email))
}) })
frontend := FrontendHandler{staticPath: "build", indexPath: "index.html"} frontend := FrontendHandler{staticPath: "build", indexPath: "index.html"}

View File

@ -9,6 +9,11 @@ import (
type SecurityConfig struct { type SecurityConfig struct {
AccessTokenExpiration time.Duration AccessTokenExpiration time.Duration
Secret []byte Secret []byte
UserAuthHeader string
}
func (c SecurityConfig) IsRemoteAuth() bool {
return c.UserAuthHeader != ""
} }
func GetSecurityConfig(accessTokenExp string, secret []byte) (SecurityConfig, error) { func GetSecurityConfig(accessTokenExp string, secret []byte) (SecurityConfig, error) {