Browse Source

Merge pull request #317 from pascoual/feature/generic-oauth

Login with generic oauth feature++
pull/376/head
Matt Baer 3 years ago
committed by GitHub
parent
commit
dfa14c9c92
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 304 additions and 57 deletions
  1. +60
    -37
      account.go
  2. +15
    -2
      app.go
  3. +16
    -0
      config/config.go
  4. +5
    -3
      database.go
  5. +2
    -0
      errors.go
  6. +27
    -0
      oauth.go
  7. +114
    -0
      oauth_generic.go
  8. +21
    -0
      pages/landing.tmpl
  9. +23
    -12
      pages/login.tmpl
  10. +1
    -0
      routes.go
  11. +20
    -3
      templates/user/settings.tmpl

+ 60
- 37
account.go View File

@@ -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)


+ 15
- 2
app.go View File

@@ -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)


+ 16
- 0
config/config.go View File

@@ -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"`
}
)


+ 5
- 3
database.go View File

@@ -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) {


+ 2
- 0
errors.go View File

@@ -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


+ 27
- 0
oauth.go View File

@@ -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"


+ 114
- 0
oauth_generic.go View File

@@ -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
}

+ 21
- 0
pages/landing.tmpl View File

@@ -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;
}
</style>
{{end}}
{{define "content"}}
@@ -73,6 +78,21 @@ form dd {

<div{{if not .OpenRegistration}} style="padding: 2em 0;"{{end}}>
{{ if .OpenRegistration }}
{{ if or .OauthSlack .OauthWriteAs .OauthGitlab .OauthGeneric }}
{{ if .OauthSlack }}
<div class="row content-container signinbtns signinoauthbtns"><a class="loginbtn" href="/oauth/slack"><img alt="Sign in with Slack" height="40" width="172" src="/img/sign_in_with_slack.png" srcset="/img/sign_in_with_slack.png 1x, /img/sign_in_with_slack@2x.png 2x" /></a></div>
{{ end }}
{{ if .OauthWriteAs }}
<div class="row content-container signinbtns signinoauthbtns"><a class="btn cta loginbtn" id="writeas-login" href="/oauth/write.as">Sign in with <strong>Write.as</strong></a></div>
{{ end }}
{{ if .OauthGitlab }}
<div class="row content-container signinbtns signinoauthbtns"><a class="btn cta loginbtn" id="gitlab-login" href="/oauth/gitlab">Sign in with <strong>{{.GitlabDisplayName}}</strong></a></div>
{{ end }}
{{ if .OauthGeneric }}
<div class="row content-container signinbtns signinoauthbtns"><a class="btn cta loginbtn" id="generic-oauth-login" href="/oauth/generic">Sign in with <strong>{{ .OauthGenericDisplayName }}</strong></a></div>
{{ end }}
{{ end }}
{{if not .DisablePasswordAuth}}
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
@@ -101,6 +121,7 @@ form dd {
</dl>
</form>
</div>
{{end}}
{{ else }}
<p style="font-size: 1.3em; margin: 1rem 0;">Registration is currently closed.</p>
<p>You can always sign up on <a href="https://writefreely.org/instances">another instance</a>.</p>


+ 23
- 12
pages/login.tmpl View File

@@ -3,6 +3,10 @@
<meta itemprop="description" content="Log in to {{.SiteName}}.">
<style>
input{margin-bottom:0.5em;}
#generic-oauth-login {
box-sizing: border-box;
font-size: 17px;
}
</style>
{{end}}
{{define "content"}}
@@ -13,7 +17,7 @@ input{margin-bottom:0.5em;}
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}

{{ if or .OauthSlack .OauthWriteAs .OauthGitlab .OauthGitea }}
{{ if or .OauthSlack .OauthWriteAs .OauthGitlab .OauthGeneric .OauthGitea }}
<div class="row content-container signinbtns">
{{ if .OauthSlack }}
<a class="loginbtn" href="/oauth/slack"><img alt="Sign in with Slack" height="40" width="172" src="/img/sign_in_with_slack.png" srcset="/img/sign_in_with_slack.png 1x, /img/sign_in_with_slack@2x.png 2x" /></a>
@@ -24,17 +28,23 @@ input{margin-bottom:0.5em;}
{{ if .OauthGitlab }}
<a class="btn cta loginbtn" id="gitlab-login" href="/oauth/gitlab">Sign in with <strong>{{.GitlabDisplayName}}</strong></a>
{{ end }}
{{ if .OauthGeneric }}
<a class="btn cta loginbtn" id="generic-oauth-login" href="/oauth/generic">Sign in with <strong>{{ .OauthGenericDisplayName }}</strong></a>
{{ end }}
{{ if .OauthGitea }}
<a class="btn cta loginbtn" id="gitea-login" href="/oauth/gitea">Sign in with <strong>{{.GiteaDisplayName}}</strong></a>
{{ end }}
</div>

<div class="or">
<p>or</p>
<hr class="short" />
</div>
{{if not .DisablePasswordAuth}}
<div class="or">
<p>or</p>
<hr class="short" />
</div>
{{end}}
{{ end }}

{{if not .DisablePasswordAuth}}
<form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
<input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br />
<input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br />
@@ -44,11 +54,12 @@ input{margin-bottom:0.5em;}

{{if and (not .SingleUser) .OpenRegistration}}<p style="text-align:center;font-size:0.9em;margin:3em auto;max-width:26em;">{{if .Message}}{{.Message}}{{else}}<em>No account yet?</em> <a href="{{.SignupPath}}">Sign up</a> to start a blog.{{end}}</p>{{end}}

<script type="text/javascript">
function disableSubmit() {
var $btn = document.getElementById("btn-login");
$btn.value = "Logging in...";
$btn.disabled = true;
}
</script>
<script type="text/javascript">
function disableSubmit() {
var $btn = document.getElementById("btn-login");
$btn.value = "Logging in...";
$btn.disabled = true;
}
</script>
{{end}}
{{end}}

+ 1
- 0
routes.go View File

@@ -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


+ 20
- 3
templates/user/settings.tmpl View File

@@ -41,6 +41,7 @@ h3 { font-weight: normal; }
</form>
{{ end }}

{{if not .DisablePasswordAuth}}
<form method="post" action="/api/me/self" autocomplete="false">
<input type="hidden" name="logout" value="{{.IsLogOut}}" />
<div class="option">
@@ -72,6 +73,7 @@ h3 { font-weight: normal; }
<input type="submit" value="Save changes" tabindex="4" />
</div>
</form>
{{end}}

{{ if .OauthSection }}
<hr />
@@ -86,14 +88,22 @@ h3 { font-weight: normal; }
<input type="hidden" name="client_id" value="{{ $oauth_account.ClientID }}" />
<input type="hidden" name="remote_user_id" value="{{ $oauth_account.RemoteUserID }}" />
<div class="section oauth-provider">
<img src="/img/mark/{{$oauth_account.Provider}}.png" alt="{{ $oauth_account.Provider | title }}" />
<input type="submit" value="Remove {{ $oauth_account.Provider | title }}" />
{{ if $oauth_account.DisplayName}}
{{ if $oauth_account.AllowDisconnect}}
<input type="submit" value="Remove {{.DisplayName}}" />
{{else}}
<a class="btn cta"><strong>{{.DisplayName}}</strong></a>
{{end}}
{{else}}
<img src="/img/mark/{{$oauth_account.Provider}}.png" alt="{{ $oauth_account.Provider | title }}" />
<input type="submit" value="Remove {{ $oauth_account.Provider | title }}" />
{{end}}
</div>
</form>
{{ end }}
</div>
{{ end }}
{{ if or .OauthSlack .OauthWriteAs .OauthGitLab .OauthGitea }}
{{ if or .OauthSlack .OauthWriteAs .OauthGitLab .OauthGeneric .OauthGitea }}
<div class="option">
<h2>Link External Accounts</h2>
<p>Connect additional accounts to enable logging in with those providers, instead of using your username and password.</p>
@@ -131,6 +141,13 @@ h3 { font-weight: normal; }
</div>
{{ end }}
</div>
{{ if .OauthGeneric }}
<div class="row">
<div class="section oauth-provider">
<p><a class="btn cta loginbtn" id="generic-oauth-login" href="/oauth/generic?attach=t">Link <strong>{{ .OauthGenericDisplayName }}</strong></a></p>
</div>
</div>
{{ end }}
</div>
{{ end }}
{{ end }}


Loading…
Cancel
Save