From c12a74592941e2038ca2a63b598a53ad108f039b Mon Sep 17 00:00:00 2001 From: Maxim Kernozhitskiy Date: Mon, 18 Oct 2021 01:12:13 +0300 Subject: [PATCH] [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. --- .tmuxinator.yml | 2 +- conf/taskcafe.example.toml | 1 + docs/remote-auth.md | 9 +++++++ internal/commands/commands.go | 1 + internal/commands/web.go | 25 +++++++++++++------ internal/route/auth.go | 47 +++++++++++++++++++++++++++++++++++ internal/route/route.go | 11 +++++--- internal/utils/security.go | 5 ++++ 8 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 docs/remote-auth.md diff --git a/.tmuxinator.yml b/.tmuxinator.yml index 2276239e..f285f5d2 100644 --- a/.tmuxinator.yml +++ b/.tmuxinator.yml @@ -21,4 +21,4 @@ windows: - database: root: ./ panes: - - pgcli postgres://taskcafe:taskcafe_test@localhost:8855/taskcafe + - pgcli postgres://taskcafe:taskcafe_test@localhost:8865/taskcafe diff --git a/conf/taskcafe.example.toml b/conf/taskcafe.example.toml index 40c42d71..c204e106 100644 --- a/conf/taskcafe.example.toml +++ b/conf/taskcafe.example.toml @@ -1,5 +1,6 @@ [server] hostname = '0.0.0.0:3333' +remote_user_header = "" [email_notifications] enabled = true diff --git a/docs/remote-auth.md b/docs/remote-auth.md new file mode 100644 index 00000000..ce274eb5 --- /dev/null +++ b/docs/remote-auth.md @@ -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. \ No newline at end of file diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 1068bc31..5424f69f 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -81,6 +81,7 @@ func Execute() { viper.SetDefault("database.password", "taskcafe_test") viper.SetDefault("database.port", "5432") viper.SetDefault("security.token_expiration", "15m") + viper.SetDefault("server.remote_user_header", "") viper.SetDefault("queue.broker", "amqp://guest:guest@localhost:5672/") viper.SetDefault("queue.store", "memcache://localhost:11211") diff --git a/internal/commands/web.go b/internal/commands/web.go index 020184df..b9bd5c0f 100644 --- a/internal/commands/web.go +++ b/internal/commands/web.go @@ -75,15 +75,24 @@ func newWebCmd() *cobra.Command { log.Warn("server.secret is not set, generating a random secret") secret = uuid.New().String() } + security, err := utils.GetSecurityConfig(viper.GetString("security.token_expiration"), []byte(secret)) - r, _ := route.NewRouter(db, utils.EmailConfig{ - 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) + if err != nil { + log.Error(err) + } + security.UserAuthHeader = viper.GetString("server.remote_user_header") + + r, _ := route.NewRouter(db, route.Config{ + Email: utils.EmailConfig{ + 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") return http.ListenAndServe(viper.GetString("server.hostname"), r) }, diff --git a/internal/route/auth.go b/internal/route/auth.go index a73497bb..d89f0ad0 100644 --- a/internal/route/auth.go +++ b/internal/route/auth.go @@ -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 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 err := json.NewDecoder(r.Body).Decode(&requestData) if err != nil { @@ -139,9 +148,47 @@ func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) { 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) + // 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") diff --git a/internal/route/route.go b/internal/route/route.go index 85d5683d..09b3bbf5 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -65,8 +65,13 @@ type TaskcafeHandler struct { SecurityConfig utils.SecurityConfig } +type Config struct { + Email utils.EmailConfig + Security utils.SecurityConfig +} + // 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.TimestampFormat = "02-01-2006 15:04:05" formatter.FullTimestamp = true @@ -93,7 +98,7 @@ func NewRouter(dbConnection *sqlx.DB, emailConfig utils.EmailConfig, securityCon })) repository := db.NewRepository(dbConnection) - taskcafeHandler := TaskcafeHandler{*repository, securityConfig} + taskcafeHandler := TaskcafeHandler{*repository, cfg.Security} var imgServer = http.FileServer(http.Dir("./uploads/")) 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) { mux.Use(auth.Middleware) 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"} diff --git a/internal/utils/security.go b/internal/utils/security.go index 5ca06047..07a10000 100644 --- a/internal/utils/security.go +++ b/internal/utils/security.go @@ -9,6 +9,11 @@ import ( type SecurityConfig struct { AccessTokenExpiration time.Duration Secret []byte + UserAuthHeader string +} + +func (c SecurityConfig) IsRemoteAuth() bool { + return c.UserAuthHeader != "" } func GetSecurityConfig(accessTokenExp string, secret []byte) (SecurityConfig, error) {