diff --git a/account.go b/account.go index 0a3db90..fbe5ad0 100644 --- a/account.go +++ b/account.go @@ -86,6 +86,11 @@ func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error { } func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) { + if app.cfg.App.DisablePasswordAuth { + err := ErrDisabledPasswordAuth + return nil, err + } + reqJSON := IsJSON(r) // Get params @@ -299,16 +304,18 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error { p := &struct { page.StaticPage - To string - Message template.HTML - Flashes []template.HTML - LoginUsername string - OauthSlack bool - OauthWriteAs bool - OauthGitlab bool - GitlabDisplayName string - OauthGitea bool - GiteaDisplayName string + To string + Message template.HTML + Flashes []template.HTML + LoginUsername string + OauthSlack bool + OauthWriteAs bool + OauthGitlab bool + GitlabDisplayName string + OauthGeneric bool + OauthGenericDisplayName string + OauthGitea bool + GiteaDisplayName string }{ pageForReq(app, r), r.FormValue("to"), @@ -318,6 +325,8 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error { app.Config().SlackOauth.ClientID != "", app.Config().WriteAsOauth.ClientID != "", app.Config().GitlabOauth.ClientID != "", + config.OrDefaultString(app.Config().GenericOauth.DisplayName, genericOauthDisplayName), + app.Config().GenericOauth.ClientID != "", config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName), app.Config().GiteaOauth.ClientID != "", config.OrDefaultString(app.Config().GiteaOauth.DisplayName, giteaDisplayName), @@ -395,6 +404,11 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error { var err error var signin userCredentials + if app.cfg.App.DisablePasswordAuth { + err := ErrDisabledPasswordAuth + return err + } + // Log in with one-time token if one is given if oneTimeToken != "" { log.Info("Login: Logging user in via token.") @@ -1049,6 +1063,7 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err enableOauthSlack := app.Config().SlackOauth.ClientID != "" enableOauthWriteAs := app.Config().WriteAsOauth.ClientID != "" enableOauthGitLab := app.Config().GitlabOauth.ClientID != "" + enableOauthGeneric := app.Config().GenericOauth.ClientID != "" enableOauthGitea := app.Config().GiteaOauth.ClientID != "" oauthAccounts, err := app.db.GetOauthAccounts(r.Context(), u.ID) @@ -1056,7 +1071,7 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err log.Error("Unable to get oauth accounts for settings: %s", err) return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."} } - for _, oauthAccount := range oauthAccounts { + for idx, oauthAccount := range oauthAccounts { switch oauthAccount.Provider { case "slack": enableOauthSlack = false @@ -1064,41 +1079,49 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err enableOauthWriteAs = false case "gitlab": enableOauthGitLab = false + case "generic": + oauthAccounts[idx].DisplayName = app.Config().GenericOauth.DisplayName + oauthAccounts[idx].AllowDisconnect = app.Config().GenericOauth.AllowDisconnect + enableOauthGeneric = false case "gitea": enableOauthGitea = false } } - displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || enableOauthGitea || len(oauthAccounts) > 0 + displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || enableOauthGeneric || enableOauthGitea || len(oauthAccounts) > 0 obj := struct { *UserPage - Email string - HasPass bool - IsLogOut bool - Silenced bool - OauthSection bool - OauthAccounts []oauthAccountInfo - OauthSlack bool - OauthWriteAs bool - OauthGitLab bool - GitLabDisplayName string - OauthGitea bool - GiteaDisplayName string + Email string + HasPass bool + IsLogOut bool + Silenced bool + OauthSection bool + OauthAccounts []oauthAccountInfo + OauthSlack bool + OauthWriteAs bool + OauthGitLab bool + GitLabDisplayName string + OauthGeneric bool + OauthGenericDisplayName string + OauthGitea bool + GiteaDisplayName string }{ - UserPage: NewUserPage(app, r, u, "Account Settings", flashes), - Email: fullUser.EmailClear(app.keys), - HasPass: passIsSet, - IsLogOut: r.FormValue("logout") == "1", - Silenced: fullUser.IsSilenced(), - OauthSection: displayOauthSection, - OauthAccounts: oauthAccounts, - OauthSlack: enableOauthSlack, - OauthWriteAs: enableOauthWriteAs, - OauthGitLab: enableOauthGitLab, - GitLabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName), - OauthGitea: enableOauthGitea, - GiteaDisplayName: config.OrDefaultString(app.Config().GiteaOauth.DisplayName, giteaDisplayName), + UserPage: NewUserPage(app, r, u, "Account Settings", flashes), + Email: fullUser.EmailClear(app.keys), + HasPass: passIsSet, + IsLogOut: r.FormValue("logout") == "1", + Silenced: fullUser.IsSilenced(), + OauthSection: displayOauthSection, + OauthAccounts: oauthAccounts, + OauthSlack: enableOauthSlack, + OauthWriteAs: enableOauthWriteAs, + OauthGitLab: enableOauthGitLab, + GitLabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName), + OauthGeneric: enableOauthGeneric, + OauthGenericDisplayName: config.OrDefaultString(app.Config().GenericOauth.DisplayName, genericOauthDisplayName), + OauthGitea: enableOauthGitea, + GiteaDisplayName: config.OrDefaultString(app.Config().GiteaOauth.DisplayName, giteaDisplayName), } showUserPage(w, "settings", obj) diff --git a/app.go b/app.go index 2ba43fc..af0a56f 100644 --- a/app.go +++ b/app.go @@ -243,9 +243,22 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error { Content template.HTML ForcedLanding bool + + OauthSlack bool + OauthWriteAs bool + OauthGitlab bool + OauthGeneric bool + OauthGenericDisplayName string + GitlabDisplayName string }{ - StaticPage: pageForReq(app, r), - ForcedLanding: forceLanding, + StaticPage: pageForReq(app, r), + ForcedLanding: forceLanding, + OauthSlack: app.Config().SlackOauth.ClientID != "", + OauthWriteAs: app.Config().WriteAsOauth.ClientID != "", + OauthGitlab: app.Config().GitlabOauth.ClientID != "", + OauthGeneric: app.Config().GenericOauth.ClientID != "", + OauthGenericDisplayName: config.OrDefaultString(app.Config().GenericOauth.DisplayName, genericOauthDisplayName), + GitlabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName), } banner, err := getLandingBanner(app) diff --git a/config/config.go b/config/config.go index 0910cf5..9ff13f8 100644 --- a/config/config.go +++ b/config/config.go @@ -89,6 +89,18 @@ type ( CallbackProxyAPI string `ini:"callback_proxy_api"` } + GenericOauthCfg struct { + ClientID string `ini:"client_id"` + ClientSecret string `ini:"client_secret"` + Host string `ini:"host"` + DisplayName string `ini:"display_name"` + CallbackProxy string `ini:"callback_proxy"` + CallbackProxyAPI string `ini:"callback_proxy_api"` + TokenEndpoint string `ini:"token_endpoint"` + InspectEndpoint string `ini:"inspect_endpoint"` + AuthEndpoint string `ini:"auth_endpoint"` + AllowDisconnect bool `ini:"allow_disconnect"` + } GiteaOauthCfg struct { ClientID string `ini:"client_id"` ClientSecret string `ini:"client_secret"` @@ -140,6 +152,9 @@ type ( // Check for Updates UpdateChecks bool `ini:"update_checks"` + + // Disable password authentication if use only Oauth + DisablePasswordAuth bool `ini:"disable_password_auth"` } // Config holds the complete configuration for running a writefreely instance @@ -150,6 +165,7 @@ type ( SlackOauth SlackOauthCfg `ini:"oauth.slack"` WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"` GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"` + GenericOauth GenericOauthCfg `ini:"oauth.generic"` GiteaOauth GiteaOauthCfg `ini:"oauth.gitea"` } ) diff --git a/database.go b/database.go index 7e897b4..c764340 100644 --- a/database.go +++ b/database.go @@ -2627,9 +2627,11 @@ func (db *datastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provi } type oauthAccountInfo struct { - Provider string - ClientID string - RemoteUserID string + Provider string + ClientID string + RemoteUserID string + DisplayName string + AllowDisconnect bool } func (db *datastore) GetOauthAccounts(ctx context.Context, userID int64) ([]oauthAccountInfo, error) { diff --git a/errors.go b/errors.go index 579386b..cf52df1 100644 --- a/errors.go +++ b/errors.go @@ -52,6 +52,8 @@ var ( ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} ErrUserSilenced = impart.HTTPError{http.StatusForbidden, "Account is silenced."} + + ErrDisabledPasswordAuth = impart.HTTPError{http.StatusForbidden, "Password authentication is disabled."} ) // Post operation errors diff --git a/oauth.go b/oauth.go index 7e7bee6..fe9fe74 100644 --- a/oauth.go +++ b/oauth.go @@ -235,6 +235,33 @@ func configureGitlabOauth(parentHandler *Handler, r *mux.Router, app *App) { } } +func configureGenericOauth(parentHandler *Handler, r *mux.Router, app *App) { + if app.Config().GenericOauth.ClientID != "" { + callbackLocation := app.Config().App.Host + "/oauth/callback/generic" + + var callbackProxy *callbackProxyClient = nil + if app.Config().GenericOauth.CallbackProxy != "" { + callbackProxy = &callbackProxyClient{ + server: app.Config().GenericOauth.CallbackProxyAPI, + callbackLocation: app.Config().App.Host + "/oauth/callback/generic", + httpClient: config.DefaultHTTPClient(), + } + callbackLocation = app.Config().GenericOauth.CallbackProxy + } + + oauthClient := genericOauthClient{ + ClientID: app.Config().GenericOauth.ClientID, + ClientSecret: app.Config().GenericOauth.ClientSecret, + ExchangeLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.TokenEndpoint, + InspectLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.InspectEndpoint, + AuthLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.AuthEndpoint, + HttpClient: config.DefaultHTTPClient(), + CallbackLocation: callbackLocation, + } + configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy) + } +} + func configureGiteaOauth(parentHandler *Handler, r *mux.Router, app *App) { if app.Config().GiteaOauth.ClientID != "" { callbackLocation := app.Config().App.Host + "/oauth/callback/gitea" diff --git a/oauth_generic.go b/oauth_generic.go new file mode 100644 index 0000000..42c84b0 --- /dev/null +++ b/oauth_generic.go @@ -0,0 +1,114 @@ +package writefreely + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" +) + +type genericOauthClient struct { + ClientID string + ClientSecret string + AuthLocation string + ExchangeLocation string + InspectLocation string + CallbackLocation string + HttpClient HttpClient +} + +var _ oauthClient = genericOauthClient{} + +const ( + genericOauthDisplayName = "OAuth" +) + +func (c genericOauthClient) GetProvider() string { + return "generic" +} + +func (c genericOauthClient) GetClientID() string { + return c.ClientID +} + +func (c genericOauthClient) GetCallbackLocation() string { + return c.CallbackLocation +} + +func (c genericOauthClient) buildLoginURL(state string) (string, error) { + u, err := url.Parse(c.AuthLocation) + if err != nil { + return "", err + } + q := u.Query() + q.Set("client_id", c.ClientID) + q.Set("redirect_uri", c.CallbackLocation) + q.Set("response_type", "code") + q.Set("state", state) + q.Set("scope", "read_user") + u.RawQuery = q.Encode() + return u.String(), nil +} + +func (c genericOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) { + form := url.Values{} + form.Add("grant_type", "authorization_code") + form.Add("redirect_uri", c.CallbackLocation) + form.Add("scope", "read_user") + form.Add("code", code) + req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.WithContext(ctx) + req.Header.Set("User-Agent", "writefreely") + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(c.ClientID, c.ClientSecret) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.New("unable to exchange code for access token") + } + + var tokenResponse TokenResponse + if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil { + return nil, err + } + if tokenResponse.Error != "" { + return nil, errors.New(tokenResponse.Error) + } + return &tokenResponse, nil +} + +func (c genericOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) { + req, err := http.NewRequest("GET", c.InspectLocation, nil) + if err != nil { + return nil, err + } + req.WithContext(ctx) + req.Header.Set("User-Agent", "writefreely") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.New("unable to inspect access token") + } + + var inspectResponse InspectResponse + if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &inspectResponse); err != nil { + return nil, err + } + if inspectResponse.Error != "" { + return nil, errors.New(inspectResponse.Error) + } + return &inspectResponse, nil +} diff --git a/pages/landing.tmpl b/pages/landing.tmpl index d3867a9..f968404 100644 --- a/pages/landing.tmpl +++ b/pages/landing.tmpl @@ -60,6 +60,11 @@ form dd { margin-top: 0; max-width: 8em; } +#generic-oauth-login { + box-sizing: border-box; + font-size: 17px; + white-space:nowrap; +} {{end}} {{define "content"}} @@ -73,6 +78,21 @@ form dd { {{ if .OpenRegistration }} + {{ if or .OauthSlack .OauthWriteAs .OauthGitlab .OauthGeneric }} + {{ if .OauthSlack }} +
Sign in with Slack
+ {{ end }} + {{ if .OauthWriteAs }} +
Sign in with Write.as
+ {{ end }} + {{ if .OauthGitlab }} +
Sign in with {{.GitlabDisplayName}}
+ {{ end }} + {{ if .OauthGeneric }} +
Sign in with {{ .OauthGenericDisplayName }}
+ {{ end }} + {{ end }} + {{if not .DisablePasswordAuth}} {{if .Flashes}}{{end}} @@ -101,6 +121,7 @@ form dd { + {{end}} {{ else }}

Registration is currently closed.

You can always sign up on another instance.

diff --git a/pages/login.tmpl b/pages/login.tmpl index f55d4ff..9a65da2 100644 --- a/pages/login.tmpl +++ b/pages/login.tmpl @@ -3,6 +3,10 @@ {{end}} {{define "content"}} @@ -13,7 +17,7 @@ input{margin-bottom:0.5em;} {{range .Flashes}}
  • {{.}}
  • {{end}} {{end}} - {{ if or .OauthSlack .OauthWriteAs .OauthGitlab .OauthGitea }} + {{ if or .OauthSlack .OauthWriteAs .OauthGitlab .OauthGeneric .OauthGitea }}
    {{ if .OauthSlack }} Sign in with Slack @@ -24,17 +28,23 @@ input{margin-bottom:0.5em;} {{ if .OauthGitlab }} Sign in with {{.GitlabDisplayName}} {{ end }} + {{ if .OauthGeneric }} + Sign in with {{ .OauthGenericDisplayName }} + {{ end }} {{ if .OauthGitea }} Sign in with {{.GiteaDisplayName}} {{ end }}
    -
    -

    or

    -
    -
    + {{if not .DisablePasswordAuth}} +
    +

    or

    +
    +
    + {{end}} {{ end }} +{{if not .DisablePasswordAuth}}


    @@ -44,11 +54,12 @@ input{margin-bottom:0.5em;} {{if and (not .SingleUser) .OpenRegistration}}

    {{if .Message}}{{.Message}}{{else}}No account yet? Sign up to start a blog.{{end}}

    {{end}} - + + {{end}} {{end}} diff --git a/routes.go b/routes.go index a6efa35..47c4f19 100644 --- a/routes.go +++ b/routes.go @@ -76,6 +76,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { configureSlackOauth(handler, write, apper.App()) configureWriteAsOauth(handler, write, apper.App()) configureGitlabOauth(handler, write, apper.App()) + configureGenericOauth(handler, write, apper.App()) configureGiteaOauth(handler, write, apper.App()) // Set up dyamic page handlers diff --git a/templates/user/settings.tmpl b/templates/user/settings.tmpl index 1808881..b6abadc 100644 --- a/templates/user/settings.tmpl +++ b/templates/user/settings.tmpl @@ -41,6 +41,7 @@ h3 { font-weight: normal; }
    {{ end }} + {{if not .DisablePasswordAuth}}
    @@ -72,6 +73,7 @@ h3 { font-weight: normal; }
    + {{end}} {{ if .OauthSection }}
    @@ -86,14 +88,22 @@ h3 { font-weight: normal; }
    - {{ $oauth_account.Provider | title }} - + {{ if $oauth_account.DisplayName}} + {{ if $oauth_account.AllowDisconnect}} + + {{else}} + {{.DisplayName}} + {{end}} + {{else}} + {{ $oauth_account.Provider | title }} + + {{end}}
    {{ end }} {{ end }} - {{ if or .OauthSlack .OauthWriteAs .OauthGitLab .OauthGitea }} + {{ if or .OauthSlack .OauthWriteAs .OauthGitLab .OauthGeneric .OauthGitea }}

    Link External Accounts

    Connect additional accounts to enable logging in with those providers, instead of using your username and password.

    @@ -131,6 +141,13 @@ h3 { font-weight: normal; }
    {{ end }} + {{ if .OauthGeneric }} +
    + +
    + {{ end }} {{ end }} {{ end }}