diff --git a/config/config.go b/config/config.go index 56f83a5..2616e9e 100644 --- a/config/config.go +++ b/config/config.go @@ -1,210 +1,212 @@ /* * Copyright © 2018-2019 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ // Package config holds and assists in the configuration of a writefreely instance. package config import ( "gopkg.in/ini.v1" "strings" ) const ( // FileName is the default configuration file name FileName = "config.ini" UserNormal UserType = "user" UserAdmin = "admin" ) type ( UserType string // ServerCfg holds values that affect how the HTTP server runs ServerCfg struct { HiddenHost string `ini:"hidden_host"` Port int `ini:"port"` Bind string `ini:"bind"` TLSCertPath string `ini:"tls_cert_path"` TLSKeyPath string `ini:"tls_key_path"` Autocert bool `ini:"autocert"` TemplatesParentDir string `ini:"templates_parent_dir"` StaticParentDir string `ini:"static_parent_dir"` PagesParentDir string `ini:"pages_parent_dir"` KeysParentDir string `ini:"keys_parent_dir"` HashSeed string `ini:"hash_seed"` Dev bool `ini:"-"` } // DatabaseCfg holds values that determine how the application connects to a datastore DatabaseCfg struct { Type string `ini:"type"` FileName string `ini:"filename"` User string `ini:"username"` Password string `ini:"password"` Database string `ini:"database"` Host string `ini:"host"` Port int `ini:"port"` } WriteAsOauthCfg struct { - ClientID string `ini:"client_id"` - ClientSecret string `ini:"client_secret"` - AuthLocation string `ini:"auth_location"` - TokenLocation string `ini:"token_location"` - InspectLocation string `ini:"inspect_location"` - StateRegisterLocation string `ini:"state_register_location"` + ClientID string `ini:"client_id"` + ClientSecret string `ini:"client_secret"` + AuthLocation string `ini:"auth_location"` + TokenLocation string `ini:"token_location"` + InspectLocation string `ini:"inspect_location"` + CallbackProxy string `ini:"callback_proxy"` + CallbackProxyAPI string `ini:"callback_proxy_api"` } SlackOauthCfg struct { - ClientID string `ini:"client_id"` - ClientSecret string `ini:"client_secret"` - TeamID string `ini:"team_id"` - StateRegisterLocation string `ini:"state_register_location"` + ClientID string `ini:"client_id"` + ClientSecret string `ini:"client_secret"` + TeamID string `ini:"team_id"` + CallbackProxy string `ini:"callback_proxy"` + CallbackProxyAPI string `ini:"callback_proxy_api"` } // AppCfg holds values that affect how the application functions AppCfg struct { SiteName string `ini:"site_name"` SiteDesc string `ini:"site_description"` Host string `ini:"host"` // Site appearance Theme string `ini:"theme"` Editor string `ini:"editor"` JSDisabled bool `ini:"disable_js"` WebFonts bool `ini:"webfonts"` Landing string `ini:"landing"` SimpleNav bool `ini:"simple_nav"` WFModesty bool `ini:"wf_modesty"` // Site functionality Chorus bool `ini:"chorus"` DisableDrafts bool `ini:"disable_drafts"` // Users SingleUser bool `ini:"single_user"` OpenRegistration bool `ini:"open_registration"` MinUsernameLen int `ini:"min_username_len"` MaxBlogs int `ini:"max_blogs"` // Federation Federation bool `ini:"federation"` PublicStats bool `ini:"public_stats"` // Access Private bool `ini:"private"` // Additional functions LocalTimeline bool `ini:"local_timeline"` UserInvites string `ini:"user_invites"` // Defaults DefaultVisibility string `ini:"default_visibility"` } // Config holds the complete configuration for running a writefreely instance Config struct { Server ServerCfg `ini:"server"` Database DatabaseCfg `ini:"database"` App AppCfg `ini:"app"` SlackOauth SlackOauthCfg `ini:"oauth.slack"` WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"` } ) // New creates a new Config with sane defaults func New() *Config { c := &Config{ Server: ServerCfg{ Port: 8080, Bind: "localhost", /* IPV6 support when not using localhost? */ }, App: AppCfg{ Host: "http://localhost:8080", Theme: "write", WebFonts: true, SingleUser: true, MinUsernameLen: 3, MaxBlogs: 1, Federation: true, PublicStats: true, }, } c.UseMySQL(true) return c } // UseMySQL resets the Config's Database to use default values for a MySQL setup. func (cfg *Config) UseMySQL(fresh bool) { cfg.Database.Type = "mysql" if fresh { cfg.Database.Host = "localhost" cfg.Database.Port = 3306 } } // UseSQLite resets the Config's Database to use default values for a SQLite setup. func (cfg *Config) UseSQLite(fresh bool) { cfg.Database.Type = "sqlite3" if fresh { cfg.Database.FileName = "writefreely.db" } } // IsSecureStandalone returns whether or not the application is running as a // standalone server with TLS enabled. func (cfg *Config) IsSecureStandalone() bool { return cfg.Server.Port == 443 && cfg.Server.TLSCertPath != "" && cfg.Server.TLSKeyPath != "" } func (ac *AppCfg) LandingPath() string { if !strings.HasPrefix(ac.Landing, "/") { return "/" + ac.Landing } return ac.Landing } // Load reads the given configuration file, then parses and returns it as a Config. func Load(fname string) (*Config, error) { if fname == "" { fname = FileName } cfg, err := ini.Load(fname) if err != nil { return nil, err } // Parse INI file uc := &Config{} err = cfg.MapTo(uc) if err != nil { return nil, err } return uc, nil } // Save writes the given Config to the given file. func Save(uc *Config, fname string) error { cfg := ini.Empty() err := ini.ReflectFrom(cfg, uc) if err != nil { return err } if fname == "" { fname = FileName } return cfg.SaveTo(fname) } diff --git a/oauth.go b/oauth.go index 363999a..eb47c91 100644 --- a/oauth.go +++ b/oauth.go @@ -1,281 +1,286 @@ package writefreely import ( "context" "encoding/json" - "errors" "fmt" "github.com/gorilla/mux" "github.com/gorilla/sessions" "github.com/writeas/impart" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/config" "io" "io/ioutil" "net/http" "net/url" "strings" "time" ) // TokenResponse contains data returned when a token is created either // through a code exchange or using a refresh token. type TokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` RefreshToken string `json:"refresh_token"` TokenType string `json:"token_type"` Error string `json:"error"` } // InspectResponse contains data returned when an access token is inspected. type InspectResponse struct { ClientID string `json:"client_id"` UserID string `json:"user_id"` ExpiresAt time.Time `json:"expires_at"` Username string `json:"username"` DisplayName string `json:"-"` Email string `json:"email"` Error string `json:"error"` } // tokenRequestMaxLen is the most bytes that we'll read from the /oauth/token // endpoint. One megabyte is plenty. const tokenRequestMaxLen = 1000000 // infoRequestMaxLen is the most bytes that we'll read from the // /oauth/inspect endpoint. const infoRequestMaxLen = 1000000 // OAuthDatastoreProvider provides a minimal interface of data store, config, // and session store for use with the oauth handlers. type OAuthDatastoreProvider interface { DB() OAuthDatastore Config() *config.Config SessionStore() sessions.Store } // OAuthDatastore provides a minimal interface of data store methods used in // oauth functionality. type OAuthDatastore interface { GetIDForRemoteUser(context.Context, string, string, string) (int64, error) RecordRemoteUserID(context.Context, int64, string, string, string, string) error ValidateOAuthState(context.Context, string) (string, string, error) GenerateOAuthState(context.Context, string, string) (string, error) CreateUser(*config.Config, *User, string) error GetUserByID(int64) (*User, error) } type HttpClient interface { Do(req *http.Request) (*http.Response, error) } type oauthClient interface { GetProvider() string GetClientID() string GetCallbackLocation() string buildLoginURL(state string) (string, error) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) } -type oauthStateRegisterer struct { - location string +type callbackProxyClient struct { + server string callbackLocation string httpClient HttpClient } type oauthHandler struct { - Config *config.Config - DB OAuthDatastore - Store sessions.Store - EmailKey []byte - oauthClient oauthClient - oauthStateRegisterer *oauthStateRegisterer + Config *config.Config + DB OAuthDatastore + Store sessions.Store + EmailKey []byte + oauthClient oauthClient + callbackProxy *callbackProxyClient } func (h oauthHandler) viewOauthInit(app *App, w http.ResponseWriter, r *http.Request) error { ctx := r.Context() state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID()) if err != nil { return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"} } - if h.oauthStateRegisterer != nil { - if err := h.oauthStateRegisterer.register(ctx, state); err != nil { - return impart.HTTPError{http.StatusInternalServerError, "could not register state location"} + if h.callbackProxy != nil { + if err := h.callbackProxy.register(ctx, state); err != nil { + return impart.HTTPError{http.StatusInternalServerError, "could not register state server"} } } location, err := h.oauthClient.buildLoginURL(state) if err != nil { return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"} } return impart.HTTPError{http.StatusTemporaryRedirect, location} } func configureSlackOauth(parentHandler *Handler, r *mux.Router, app *App) { if app.Config().SlackOauth.ClientID != "" { - var stateRegisterClient *oauthStateRegisterer = nil - if app.Config().SlackOauth.StateRegisterLocation != "" { - stateRegisterClient = &oauthStateRegisterer{ - location: app.Config().SlackOauth.StateRegisterLocation, + callbackLocation := app.Config().App.Host + "/oauth/callback" + + var stateRegisterClient *callbackProxyClient = nil + if app.Config().SlackOauth.CallbackProxyAPI != "" { + stateRegisterClient = &callbackProxyClient{ + server: app.Config().SlackOauth.CallbackProxyAPI, callbackLocation: app.Config().App.Host + "/oauth/callback", httpClient: config.DefaultHTTPClient(), } + callbackLocation = app.Config().SlackOauth.CallbackProxy } oauthClient := slackOauthClient{ - ClientID: app.Config().SlackOauth.ClientID, - ClientSecret: app.Config().SlackOauth.ClientSecret, - TeamID: app.Config().SlackOauth.TeamID, - HttpClient: config.DefaultHTTPClient(), - //CallbackLocation: app.Config().App.Host + "/oauth/callback", - CallbackLocation: "http://localhost:5000/callback", + ClientID: app.Config().SlackOauth.ClientID, + ClientSecret: app.Config().SlackOauth.ClientSecret, + TeamID: app.Config().SlackOauth.TeamID, + HttpClient: config.DefaultHTTPClient(), + CallbackLocation: callbackLocation, } configureOauthRoutes(parentHandler, r, app, oauthClient, stateRegisterClient) } } func configureWriteAsOauth(parentHandler *Handler, r *mux.Router, app *App) { if app.Config().WriteAsOauth.ClientID != "" { - var stateRegisterClient *oauthStateRegisterer = nil - if app.Config().WriteAsOauth.StateRegisterLocation != "" { - stateRegisterClient = &oauthStateRegisterer{ - location: app.Config().WriteAsOauth.StateRegisterLocation, + callbackLocation := app.Config().App.Host + "/oauth/callback" + + var callbackProxy *callbackProxyClient = nil + if app.Config().WriteAsOauth.CallbackProxy != "" { + callbackProxy = &callbackProxyClient{ + server: app.Config().WriteAsOauth.CallbackProxyAPI, callbackLocation: app.Config().App.Host + "/oauth/callback", httpClient: config.DefaultHTTPClient(), } + callbackLocation = app.Config().SlackOauth.CallbackProxy } + oauthClient := writeAsOauthClient{ ClientID: app.Config().WriteAsOauth.ClientID, ClientSecret: app.Config().WriteAsOauth.ClientSecret, ExchangeLocation: config.OrDefaultString(app.Config().WriteAsOauth.TokenLocation, writeAsExchangeLocation), InspectLocation: config.OrDefaultString(app.Config().WriteAsOauth.InspectLocation, writeAsIdentityLocation), AuthLocation: config.OrDefaultString(app.Config().WriteAsOauth.AuthLocation, writeAsAuthLocation), HttpClient: config.DefaultHTTPClient(), - CallbackLocation: "http://localhost:5000/callback", + CallbackLocation: callbackLocation, } - configureOauthRoutes(parentHandler, r, app, oauthClient, stateRegisterClient) + configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy) } } -func configureOauthRoutes(parentHandler *Handler, r *mux.Router, app *App, oauthClient oauthClient, stateRegisterClient *oauthStateRegisterer) { +func configureOauthRoutes(parentHandler *Handler, r *mux.Router, app *App, oauthClient oauthClient, callbackProxy *callbackProxyClient) { handler := &oauthHandler{ - Config: app.Config(), - DB: app.DB(), - Store: app.SessionStore(), - oauthClient: oauthClient, - EmailKey: app.keys.EmailKey, - oauthStateRegisterer: stateRegisterClient, + Config: app.Config(), + DB: app.DB(), + Store: app.SessionStore(), + oauthClient: oauthClient, + EmailKey: app.keys.EmailKey, + callbackProxy: callbackProxy, } r.HandleFunc("/oauth/"+oauthClient.GetProvider(), parentHandler.OAuth(handler.viewOauthInit)).Methods("GET") r.HandleFunc("/oauth/callback", parentHandler.OAuth(handler.viewOauthCallback)).Methods("GET") r.HandleFunc("/oauth/signup", parentHandler.OAuth(handler.viewOauthSignup)).Methods("POST") } func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http.Request) error { ctx := r.Context() code := r.FormValue("code") state := r.FormValue("state") provider, clientID, err := h.DB.ValidateOAuthState(ctx, state) if err != nil { log.Error("Unable to ValidateOAuthState: %s", err) return impart.HTTPError{http.StatusInternalServerError, err.Error()} } tokenResponse, err := h.oauthClient.exchangeOauthCode(ctx, code) if err != nil { log.Error("Unable to exchangeOauthCode: %s", err) return impart.HTTPError{http.StatusInternalServerError, err.Error()} } // Now that we have the access token, let's use it real quick to make sur // it really really works. tokenInfo, err := h.oauthClient.inspectOauthAccessToken(ctx, tokenResponse.AccessToken) if err != nil { log.Error("Unable to inspectOauthAccessToken: %s", err) return impart.HTTPError{http.StatusInternalServerError, err.Error()} } localUserID, err := h.DB.GetIDForRemoteUser(ctx, tokenInfo.UserID, provider, clientID) if err != nil { log.Error("Unable to GetIDForRemoteUser: %s", err) return impart.HTTPError{http.StatusInternalServerError, err.Error()} } if localUserID != -1 { user, err := h.DB.GetUserByID(localUserID) if err != nil { log.Error("Unable to GetUserByID %d: %s", localUserID, err) return impart.HTTPError{http.StatusInternalServerError, err.Error()} } if err = loginOrFail(h.Store, w, r, user); err != nil { log.Error("Unable to loginOrFail %d: %s", localUserID, err) return impart.HTTPError{http.StatusInternalServerError, err.Error()} } return nil } tp := &oauthSignupPageParams{ AccessToken: tokenResponse.AccessToken, TokenUsername: tokenInfo.Username, TokenAlias: tokenInfo.DisplayName, TokenEmail: tokenInfo.Email, TokenRemoteUser: tokenInfo.UserID, Provider: provider, ClientID: clientID, } tp.TokenHash = tp.HashTokenParams(h.Config.Server.HashSeed) return h.showOauthSignupPage(app, w, r, tp, nil) } -func (r *oauthStateRegisterer) register(ctx context.Context, state string) error { +func (r *callbackProxyClient) register(ctx context.Context, state string) error { form := url.Values{} form.Add("state", state) form.Add("location", r.callbackLocation) - req, err := http.NewRequestWithContext(ctx, "POST", r.location, strings.NewReader(form.Encode())) + req, err := http.NewRequestWithContext(ctx, "POST", r.server, strings.NewReader(form.Encode())) if err != nil { return err } req.Header.Set("User-Agent", "writefreely") req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := r.httpClient.Do(req) if err != nil { return err } if resp.StatusCode != http.StatusCreated { - return errors.New("register state and location") + return fmt.Errorf("unable register state location: %d", resp.StatusCode) } return nil } func limitedJsonUnmarshal(body io.ReadCloser, n int, thing interface{}) error { lr := io.LimitReader(body, int64(n+1)) data, err := ioutil.ReadAll(lr) if err != nil { return err } if len(data) == n+1 { return fmt.Errorf("content larger than max read allowance: %d", n) } return json.Unmarshal(data, thing) } func loginOrFail(store sessions.Store, w http.ResponseWriter, r *http.Request, user *User) error { // An error may be returned, but a valid session should always be returned. session, _ := store.Get(r, cookieName) session.Values[cookieUserVal] = user.Cookie() if err := session.Save(r, w); err != nil { fmt.Println("error saving session", err) return err } http.Redirect(w, r, "/", http.StatusTemporaryRedirect) return nil }