Merge pull request #232 from writeas/T712-oauth-registration-improvements
OAuth registration improvements Resolves T712
This commit is contained in:
commit
a2a9f60976
12 changed files with 498 additions and 64 deletions
|
@ -1,7 +1,7 @@
|
||||||
language: go
|
language: go
|
||||||
|
|
||||||
go:
|
go:
|
||||||
- "1.11.x"
|
- "1.13.x"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
- GO111MODULE=on
|
- GO111MODULE=on
|
||||||
|
|
|
@ -306,12 +306,16 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
Message template.HTML
|
Message template.HTML
|
||||||
Flashes []template.HTML
|
Flashes []template.HTML
|
||||||
LoginUsername string
|
LoginUsername string
|
||||||
|
OauthSlack bool
|
||||||
|
OauthWriteAs bool
|
||||||
}{
|
}{
|
||||||
pageForReq(app, r),
|
pageForReq(app, r),
|
||||||
r.FormValue("to"),
|
r.FormValue("to"),
|
||||||
template.HTML(""),
|
template.HTML(""),
|
||||||
[]template.HTML{},
|
[]template.HTML{},
|
||||||
getTempInfo(app, "login-user", r, w),
|
getTempInfo(app, "login-user", r, w),
|
||||||
|
app.Config().SlackOauth.ClientID != "",
|
||||||
|
app.Config().WriteAsOauth.ClientID != "",
|
||||||
}
|
}
|
||||||
|
|
||||||
if earlyError != "" {
|
if earlyError != "" {
|
||||||
|
|
|
@ -42,6 +42,8 @@ type (
|
||||||
PagesParentDir string `ini:"pages_parent_dir"`
|
PagesParentDir string `ini:"pages_parent_dir"`
|
||||||
KeysParentDir string `ini:"keys_parent_dir"`
|
KeysParentDir string `ini:"keys_parent_dir"`
|
||||||
|
|
||||||
|
HashSeed string `ini:"hash_seed"`
|
||||||
|
|
||||||
Dev bool `ini:"-"`
|
Dev bool `ini:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,17 +59,21 @@ type (
|
||||||
}
|
}
|
||||||
|
|
||||||
WriteAsOauthCfg struct {
|
WriteAsOauthCfg struct {
|
||||||
ClientID string `ini:"client_id"`
|
ClientID string `ini:"client_id"`
|
||||||
ClientSecret string `ini:"client_secret"`
|
ClientSecret string `ini:"client_secret"`
|
||||||
AuthLocation string `ini:"auth_location"`
|
AuthLocation string `ini:"auth_location"`
|
||||||
TokenLocation string `ini:"token_location"`
|
TokenLocation string `ini:"token_location"`
|
||||||
InspectLocation string `ini:"inspect_location"`
|
InspectLocation string `ini:"inspect_location"`
|
||||||
|
CallbackProxy string `ini:"callback_proxy"`
|
||||||
|
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||||
}
|
}
|
||||||
|
|
||||||
SlackOauthCfg struct {
|
SlackOauthCfg struct {
|
||||||
ClientID string `ini:"client_id"`
|
ClientID string `ini:"client_id"`
|
||||||
ClientSecret string `ini:"client_secret"`
|
ClientSecret string `ini:"client_secret"`
|
||||||
TeamID string `ini:"team_id"`
|
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 holds values that affect how the application functions
|
||||||
|
|
|
@ -684,18 +684,19 @@ select.inputform, textarea.inputform {
|
||||||
border: 1px solid #999;
|
border: 1px solid #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
input, button, select.inputform, textarea.inputform {
|
input, button, select.inputform, textarea.inputform, a.btn {
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
font-family: @serifFont;
|
font-family: @serifFont;
|
||||||
font-size: 100%;
|
font-size: 100%;
|
||||||
.rounded(.25em);
|
.rounded(.25em);
|
||||||
&[type=submit], &.submit {
|
&[type=submit], &.submit, &.cta {
|
||||||
border: 1px solid @primary;
|
border: 1px solid @primary;
|
||||||
background: @primary;
|
background: @primary;
|
||||||
color: white;
|
color: white;
|
||||||
.transition(0.2s);
|
.transition(0.2s);
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: lighten(@primary, 3%);
|
background-color: lighten(@primary, 3%);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
&:disabled {
|
&:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|
144
oauth.go
144
oauth.go
|
@ -7,13 +7,13 @@ import (
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/writeas/impart"
|
"github.com/writeas/impart"
|
||||||
"github.com/writeas/nerds/store"
|
|
||||||
"github.com/writeas/web-core/auth"
|
|
||||||
"github.com/writeas/web-core/log"
|
"github.com/writeas/web-core/log"
|
||||||
"github.com/writeas/writefreely/config"
|
"github.com/writeas/writefreely/config"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -73,17 +73,25 @@ type HttpClient interface {
|
||||||
type oauthClient interface {
|
type oauthClient interface {
|
||||||
GetProvider() string
|
GetProvider() string
|
||||||
GetClientID() string
|
GetClientID() string
|
||||||
|
GetCallbackLocation() string
|
||||||
buildLoginURL(state string) (string, error)
|
buildLoginURL(state string) (string, error)
|
||||||
exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error)
|
exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error)
|
||||||
inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error)
|
inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type callbackProxyClient struct {
|
||||||
|
server string
|
||||||
|
callbackLocation string
|
||||||
|
httpClient HttpClient
|
||||||
|
}
|
||||||
|
|
||||||
type oauthHandler struct {
|
type oauthHandler struct {
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
DB OAuthDatastore
|
DB OAuthDatastore
|
||||||
Store sessions.Store
|
Store sessions.Store
|
||||||
EmailKey []byte
|
EmailKey []byte
|
||||||
oauthClient oauthClient
|
oauthClient oauthClient
|
||||||
|
callbackProxy *callbackProxyClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h oauthHandler) viewOauthInit(app *App, w http.ResponseWriter, r *http.Request) error {
|
func (h oauthHandler) viewOauthInit(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
@ -92,6 +100,13 @@ func (h oauthHandler) viewOauthInit(app *App, w http.ResponseWriter, r *http.Req
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
|
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
location, err := h.oauthClient.buildLoginURL(state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
|
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
|
||||||
|
@ -101,19 +116,42 @@ func (h oauthHandler) viewOauthInit(app *App, w http.ResponseWriter, r *http.Req
|
||||||
|
|
||||||
func configureSlackOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
func configureSlackOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
||||||
if app.Config().SlackOauth.ClientID != "" {
|
if app.Config().SlackOauth.ClientID != "" {
|
||||||
|
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{
|
oauthClient := slackOauthClient{
|
||||||
ClientID: app.Config().SlackOauth.ClientID,
|
ClientID: app.Config().SlackOauth.ClientID,
|
||||||
ClientSecret: app.Config().SlackOauth.ClientSecret,
|
ClientSecret: app.Config().SlackOauth.ClientSecret,
|
||||||
TeamID: app.Config().SlackOauth.TeamID,
|
TeamID: app.Config().SlackOauth.TeamID,
|
||||||
CallbackLocation: app.Config().App.Host + "/oauth/callback",
|
|
||||||
HttpClient: config.DefaultHTTPClient(),
|
HttpClient: config.DefaultHTTPClient(),
|
||||||
|
CallbackLocation: callbackLocation,
|
||||||
}
|
}
|
||||||
configureOauthRoutes(parentHandler, r, app, oauthClient)
|
configureOauthRoutes(parentHandler, r, app, oauthClient, stateRegisterClient)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func configureWriteAsOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
func configureWriteAsOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
||||||
if app.Config().WriteAsOauth.ClientID != "" {
|
if app.Config().WriteAsOauth.ClientID != "" {
|
||||||
|
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{
|
oauthClient := writeAsOauthClient{
|
||||||
ClientID: app.Config().WriteAsOauth.ClientID,
|
ClientID: app.Config().WriteAsOauth.ClientID,
|
||||||
ClientSecret: app.Config().WriteAsOauth.ClientSecret,
|
ClientSecret: app.Config().WriteAsOauth.ClientSecret,
|
||||||
|
@ -121,22 +159,24 @@ func configureWriteAsOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
||||||
InspectLocation: config.OrDefaultString(app.Config().WriteAsOauth.InspectLocation, writeAsIdentityLocation),
|
InspectLocation: config.OrDefaultString(app.Config().WriteAsOauth.InspectLocation, writeAsIdentityLocation),
|
||||||
AuthLocation: config.OrDefaultString(app.Config().WriteAsOauth.AuthLocation, writeAsAuthLocation),
|
AuthLocation: config.OrDefaultString(app.Config().WriteAsOauth.AuthLocation, writeAsAuthLocation),
|
||||||
HttpClient: config.DefaultHTTPClient(),
|
HttpClient: config.DefaultHTTPClient(),
|
||||||
CallbackLocation: app.Config().App.Host + "/oauth/callback",
|
CallbackLocation: callbackLocation,
|
||||||
}
|
}
|
||||||
configureOauthRoutes(parentHandler, r, app, oauthClient)
|
configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func configureOauthRoutes(parentHandler *Handler, r *mux.Router, app *App, oauthClient oauthClient) {
|
func configureOauthRoutes(parentHandler *Handler, r *mux.Router, app *App, oauthClient oauthClient, callbackProxy *callbackProxyClient) {
|
||||||
handler := &oauthHandler{
|
handler := &oauthHandler{
|
||||||
Config: app.Config(),
|
Config: app.Config(),
|
||||||
DB: app.DB(),
|
DB: app.DB(),
|
||||||
Store: app.SessionStore(),
|
Store: app.SessionStore(),
|
||||||
oauthClient: oauthClient,
|
oauthClient: oauthClient,
|
||||||
EmailKey: app.keys.EmailKey,
|
EmailKey: app.keys.EmailKey,
|
||||||
|
callbackProxy: callbackProxy,
|
||||||
}
|
}
|
||||||
r.HandleFunc("/oauth/"+oauthClient.GetProvider(), parentHandler.OAuth(handler.viewOauthInit)).Methods("GET")
|
r.HandleFunc("/oauth/"+oauthClient.GetProvider(), parentHandler.OAuth(handler.viewOauthInit)).Methods("GET")
|
||||||
r.HandleFunc("/oauth/callback", parentHandler.OAuth(handler.viewOauthCallback)).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 {
|
func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
@ -171,51 +211,53 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
||||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
if localUserID == -1 {
|
if localUserID != -1 {
|
||||||
// We don't have, nor do we want, the password from the origin, so we
|
user, err := h.DB.GetUserByID(localUserID)
|
||||||
//create a random string. If the user needs to set a password, they
|
|
||||||
//can do so through the settings page or through the password reset
|
|
||||||
//flow.
|
|
||||||
randPass := store.Generate62RandomString(14)
|
|
||||||
hashedPass, err := auth.HashPass([]byte(randPass))
|
|
||||||
if err != nil {
|
|
||||||
return impart.HTTPError{http.StatusInternalServerError, "unable to create password hash"}
|
|
||||||
}
|
|
||||||
newUser := &User{
|
|
||||||
Username: tokenInfo.Username,
|
|
||||||
HashedPass: hashedPass,
|
|
||||||
HasPass: true,
|
|
||||||
Email: prepareUserEmail(tokenInfo.Email, h.EmailKey),
|
|
||||||
Created: time.Now().Truncate(time.Second).UTC(),
|
|
||||||
}
|
|
||||||
displayName := tokenInfo.DisplayName
|
|
||||||
if len(displayName) == 0 {
|
|
||||||
displayName = tokenInfo.Username
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.DB.CreateUser(h.Config, newUser, displayName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("Unable to GetUserByID %d: %s", localUserID, err)
|
||||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||||
}
|
}
|
||||||
|
if err = loginOrFail(h.Store, w, r, user); err != nil {
|
||||||
err = h.DB.RecordRemoteUserID(ctx, newUser.ID, tokenInfo.UserID, provider, clientID, tokenResponse.AccessToken)
|
log.Error("Unable to loginOrFail %d: %s", localUserID, err)
|
||||||
if err != nil {
|
|
||||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := loginOrFail(h.Store, w, r, newUser); err != nil {
|
|
||||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.DB.GetUserByID(localUserID)
|
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 *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.server, strings.NewReader(form.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
return err
|
||||||
}
|
}
|
||||||
if err = loginOrFail(h.Store, w, r, user); err != nil {
|
req.Header.Set("User-Agent", "writefreely")
|
||||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
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 fmt.Errorf("unable register state location: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
206
oauth_signup.go
Normal file
206
oauth_signup.go
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
package writefreely
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"github.com/writeas/impart"
|
||||||
|
"github.com/writeas/web-core/auth"
|
||||||
|
"github.com/writeas/web-core/log"
|
||||||
|
"github.com/writeas/writefreely/page"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type viewOauthSignupVars struct {
|
||||||
|
page.StaticPage
|
||||||
|
To string
|
||||||
|
Message template.HTML
|
||||||
|
Flashes []template.HTML
|
||||||
|
|
||||||
|
AccessToken string
|
||||||
|
TokenUsername string
|
||||||
|
TokenAlias string
|
||||||
|
TokenEmail string
|
||||||
|
TokenRemoteUser string
|
||||||
|
Provider string
|
||||||
|
ClientID string
|
||||||
|
TokenHash string
|
||||||
|
|
||||||
|
Username string
|
||||||
|
Alias string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
oauthParamAccessToken = "access_token"
|
||||||
|
oauthParamTokenUsername = "token_username"
|
||||||
|
oauthParamTokenAlias = "token_alias"
|
||||||
|
oauthParamTokenEmail = "token_email"
|
||||||
|
oauthParamTokenRemoteUserID = "token_remote_user"
|
||||||
|
oauthParamClientID = "client_id"
|
||||||
|
oauthParamProvider = "provider"
|
||||||
|
oauthParamHash = "signature"
|
||||||
|
oauthParamUsername = "username"
|
||||||
|
oauthParamAlias = "alias"
|
||||||
|
oauthParamEmail = "email"
|
||||||
|
oauthParamPassword = "password"
|
||||||
|
)
|
||||||
|
|
||||||
|
type oauthSignupPageParams struct {
|
||||||
|
AccessToken string
|
||||||
|
TokenUsername string
|
||||||
|
TokenAlias string
|
||||||
|
TokenEmail string
|
||||||
|
TokenRemoteUser string
|
||||||
|
ClientID string
|
||||||
|
Provider string
|
||||||
|
TokenHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p oauthSignupPageParams) HashTokenParams(key string) string {
|
||||||
|
hasher := sha256.New()
|
||||||
|
hasher.Write([]byte(key))
|
||||||
|
hasher.Write([]byte(p.AccessToken))
|
||||||
|
hasher.Write([]byte(p.TokenUsername))
|
||||||
|
hasher.Write([]byte(p.TokenAlias))
|
||||||
|
hasher.Write([]byte(p.TokenEmail))
|
||||||
|
hasher.Write([]byte(p.TokenRemoteUser))
|
||||||
|
hasher.Write([]byte(p.ClientID))
|
||||||
|
hasher.Write([]byte(p.Provider))
|
||||||
|
return hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
tp := &oauthSignupPageParams{
|
||||||
|
AccessToken: r.FormValue(oauthParamAccessToken),
|
||||||
|
TokenUsername: r.FormValue(oauthParamTokenUsername),
|
||||||
|
TokenAlias: r.FormValue(oauthParamTokenAlias),
|
||||||
|
TokenEmail: r.FormValue(oauthParamTokenEmail),
|
||||||
|
TokenRemoteUser: r.FormValue(oauthParamTokenRemoteUserID),
|
||||||
|
ClientID: r.FormValue(oauthParamClientID),
|
||||||
|
Provider: r.FormValue(oauthParamProvider),
|
||||||
|
}
|
||||||
|
if tp.HashTokenParams(h.Config.Server.HashSeed) != r.FormValue(oauthParamHash) {
|
||||||
|
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Request has been tampered with."}
|
||||||
|
}
|
||||||
|
tp.TokenHash = tp.HashTokenParams(h.Config.Server.HashSeed)
|
||||||
|
if err := h.validateOauthSignup(r); err != nil {
|
||||||
|
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPass, err := auth.HashPass([]byte(r.FormValue(oauthParamPassword)))
|
||||||
|
if err != nil {
|
||||||
|
return h.showOauthSignupPage(app, w, r, tp, fmt.Errorf("unable to hash password"))
|
||||||
|
}
|
||||||
|
newUser := &User{
|
||||||
|
Username: r.FormValue(oauthParamUsername),
|
||||||
|
HashedPass: hashedPass,
|
||||||
|
HasPass: true,
|
||||||
|
Email: prepareUserEmail(r.FormValue(oauthParamEmail), h.EmailKey),
|
||||||
|
Created: time.Now().Truncate(time.Second).UTC(),
|
||||||
|
}
|
||||||
|
displayName := r.FormValue(oauthParamAlias)
|
||||||
|
if len(displayName) == 0 {
|
||||||
|
displayName = r.FormValue(oauthParamUsername)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.DB.CreateUser(h.Config, newUser, displayName)
|
||||||
|
if err != nil {
|
||||||
|
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.DB.RecordRemoteUserID(r.Context(), newUser.ID, r.FormValue(oauthParamTokenRemoteUserID), r.FormValue(oauthParamProvider), r.FormValue(oauthParamClientID), r.FormValue(oauthParamAccessToken))
|
||||||
|
if err != nil {
|
||||||
|
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := loginOrFail(h.Store, w, r, newUser); err != nil {
|
||||||
|
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h oauthHandler) validateOauthSignup(r *http.Request) error {
|
||||||
|
username := r.FormValue(oauthParamUsername)
|
||||||
|
if len(username) < h.Config.App.MinUsernameLen {
|
||||||
|
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Username is too short."}
|
||||||
|
}
|
||||||
|
if len(username) > 100 {
|
||||||
|
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Username is too long."}
|
||||||
|
}
|
||||||
|
alias := r.FormValue(oauthParamAlias)
|
||||||
|
if len(alias) == 0 {
|
||||||
|
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Alias is too short."}
|
||||||
|
}
|
||||||
|
password := r.FormValue("password")
|
||||||
|
if len(password) == 0 {
|
||||||
|
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Password is too short."}
|
||||||
|
}
|
||||||
|
email := r.FormValue(oauthParamEmail)
|
||||||
|
if len(email) > 0 {
|
||||||
|
parts := strings.Split(email, "@")
|
||||||
|
if len(parts) != 2 || (len(parts[0]) < 1 || len(parts[1]) < 1) {
|
||||||
|
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Invalid email address"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h oauthHandler) showOauthSignupPage(app *App, w http.ResponseWriter, r *http.Request, tp *oauthSignupPageParams, errMsg error) error {
|
||||||
|
username := tp.TokenUsername
|
||||||
|
alias := tp.TokenAlias
|
||||||
|
email := tp.TokenEmail
|
||||||
|
|
||||||
|
session, err := app.sessionStore.Get(r, cookieName)
|
||||||
|
if err != nil {
|
||||||
|
// Ignore this
|
||||||
|
log.Error("Unable to get session; ignoring: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tmpValue := r.FormValue(oauthParamUsername); len(tmpValue) > 0 {
|
||||||
|
username = tmpValue
|
||||||
|
}
|
||||||
|
if tmpValue := r.FormValue(oauthParamAlias); len(tmpValue) > 0 {
|
||||||
|
alias = tmpValue
|
||||||
|
}
|
||||||
|
if tmpValue := r.FormValue(oauthParamEmail); len(tmpValue) > 0 {
|
||||||
|
email = tmpValue
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &viewOauthSignupVars{
|
||||||
|
StaticPage: pageForReq(app, r),
|
||||||
|
To: r.FormValue("to"),
|
||||||
|
Flashes: []template.HTML{},
|
||||||
|
|
||||||
|
AccessToken: tp.AccessToken,
|
||||||
|
TokenUsername: tp.TokenUsername,
|
||||||
|
TokenAlias: tp.TokenAlias,
|
||||||
|
TokenEmail: tp.TokenEmail,
|
||||||
|
TokenRemoteUser: tp.TokenRemoteUser,
|
||||||
|
Provider: tp.Provider,
|
||||||
|
ClientID: tp.ClientID,
|
||||||
|
TokenHash: tp.TokenHash,
|
||||||
|
|
||||||
|
Username: username,
|
||||||
|
Alias: alias,
|
||||||
|
Email: email,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display any error messages
|
||||||
|
flashes, _ := getSessionFlashes(app, w, r, session)
|
||||||
|
for _, flash := range flashes {
|
||||||
|
p.Flashes = append(p.Flashes, template.HTML(flash))
|
||||||
|
}
|
||||||
|
if errMsg != nil {
|
||||||
|
p.Flashes = append(p.Flashes, template.HTML(errMsg.Error()))
|
||||||
|
}
|
||||||
|
err = pages["signup-oauth.tmpl"].ExecuteTemplate(w, "base", p)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to render signup-oauth: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -3,6 +3,8 @@ package writefreely
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/writeas/nerds/store"
|
||||||
"github.com/writeas/slug"
|
"github.com/writeas/slug"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
@ -60,6 +62,10 @@ func (c slackOauthClient) GetClientID() string {
|
||||||
return c.ClientID
|
return c.ClientID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c slackOauthClient) GetCallbackLocation() string {
|
||||||
|
return c.CallbackLocation
|
||||||
|
}
|
||||||
|
|
||||||
func (c slackOauthClient) buildLoginURL(state string) (string, error) {
|
func (c slackOauthClient) buildLoginURL(state string) (string, error) {
|
||||||
u, err := url.Parse(slackAuthLocation)
|
u, err := url.Parse(slackAuthLocation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -151,7 +157,7 @@ func (c slackOauthClient) inspectOauthAccessToken(ctx context.Context, accessTok
|
||||||
func (resp slackUserIdentityResponse) InspectResponse() *InspectResponse {
|
func (resp slackUserIdentityResponse) InspectResponse() *InspectResponse {
|
||||||
return &InspectResponse{
|
return &InspectResponse{
|
||||||
UserID: resp.User.ID,
|
UserID: resp.User.ID,
|
||||||
Username: slug.Make(resp.User.Name),
|
Username: fmt.Sprintf("%s-%s", slug.Make(resp.User.Name), store.Generate62RandomString(5)),
|
||||||
DisplayName: resp.User.Name,
|
DisplayName: resp.User.Name,
|
||||||
Email: resp.User.Email,
|
Email: resp.User.Email,
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,10 @@ func (c writeAsOauthClient) GetClientID() string {
|
||||||
return c.ClientID
|
return c.ClientID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c writeAsOauthClient) GetCallbackLocation() string {
|
||||||
|
return c.CallbackLocation
|
||||||
|
}
|
||||||
|
|
||||||
func (c writeAsOauthClient) buildLoginURL(state string) (string, error) {
|
func (c writeAsOauthClient) buildLoginURL(state string) (string, error) {
|
||||||
u, err := url.Parse(c.AuthLocation)
|
u, err := url.Parse(c.AuthLocation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -1,7 +1,38 @@
|
||||||
{{define "head"}}<title>Log in — {{.SiteName}}</title>
|
{{define "head"}}<title>Log in — {{.SiteName}}</title>
|
||||||
<meta name="description" content="Log in to {{.SiteName}}.">
|
<meta name="description" content="Log in to {{.SiteName}}.">
|
||||||
<meta itemprop="description" content="Log in to {{.SiteName}}.">
|
<meta itemprop="description" content="Log in to {{.SiteName}}.">
|
||||||
<style>input{margin-bottom:0.5em;}</style>
|
<style>
|
||||||
|
input{margin-bottom:0.5em;}
|
||||||
|
.or {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 3.5em;
|
||||||
|
}
|
||||||
|
.or p {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: white;
|
||||||
|
padding: 0 1em;
|
||||||
|
}
|
||||||
|
.or hr {
|
||||||
|
margin-top: -1.6em;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
hr.short {
|
||||||
|
max-width: 30rem;
|
||||||
|
}
|
||||||
|
.row.signinbtns {
|
||||||
|
justify-content: space-evenly;
|
||||||
|
font-size: 1em;
|
||||||
|
margin-top: 3em;
|
||||||
|
margin-bottom: 2em;
|
||||||
|
}
|
||||||
|
.loginbtn {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
#writeas-login {
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="tight content-container">
|
<div class="tight content-container">
|
||||||
|
@ -11,6 +42,22 @@
|
||||||
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
||||||
</ul>{{end}}
|
</ul>{{end}}
|
||||||
|
|
||||||
|
{{ if or .OauthSlack .OauthWriteAs }}
|
||||||
|
<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>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .OauthWriteAs }}
|
||||||
|
<a class="btn cta loginbtn" id="writeas-login" href="/oauth/write.as">Sign in with <strong>Write.as</strong></a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="or">
|
||||||
|
<p>or</p>
|
||||||
|
<hr class="short" />
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
<form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
|
<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="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 />
|
<input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br />
|
||||||
|
|
118
pages/signup-oauth.tmpl
Normal file
118
pages/signup-oauth.tmpl
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
{{define "head"}}<title>Log in — {{.SiteName}}</title>
|
||||||
|
<meta name="description" content="Log in to {{.SiteName}}.">
|
||||||
|
<meta itemprop="description" content="Log in to {{.SiteName}}.">
|
||||||
|
<style>input{margin-bottom:0.5em;}</style>
|
||||||
|
<style type="text/css">
|
||||||
|
h2 {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
#pricing.content-container div.form-container #payment-form {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
#pricing #signup-form table {
|
||||||
|
max-width: inherit !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#pricing #payment-form table {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
max-width: inherit !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
tr.subscription {
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
#pricing.content-container tr.subscription button {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#pricing tr.subscription td {
|
||||||
|
padding: 0 0.5em;
|
||||||
|
}
|
||||||
|
#pricing table.billing > tbody > tr > td:first-child {
|
||||||
|
vertical-align: middle !important;
|
||||||
|
}
|
||||||
|
.billing-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.billing-section.bill-me {
|
||||||
|
display: table-row;
|
||||||
|
}
|
||||||
|
#btn-create {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
#total-price {
|
||||||
|
padding-left: 0.5em;
|
||||||
|
}
|
||||||
|
#alias-site.demo {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
#alias-site {
|
||||||
|
text-align: left;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
form dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div id="pricing" class="tight content-container">
|
||||||
|
<h1>Log in to {{.SiteName}}</h1>
|
||||||
|
|
||||||
|
{{if .Flashes}}<ul class="errors">
|
||||||
|
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
||||||
|
</ul>{{end}}
|
||||||
|
|
||||||
|
<div id="billing">
|
||||||
|
<form action="/oauth/signup" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
|
||||||
|
<input type="hidden" name="access_token" value="{{ .AccessToken }}" />
|
||||||
|
<input type="hidden" name="token_username" value="{{ .TokenUsername }}" />
|
||||||
|
<input type="hidden" name="token_alias" value="{{ .TokenAlias }}" />
|
||||||
|
<input type="hidden" name="token_email" value="{{ .TokenEmail }}" />
|
||||||
|
<input type="hidden" name="token_remote_user" value="{{ .TokenRemoteUser }}" />
|
||||||
|
<input type="hidden" name="provider" value="{{ .Provider }}" />
|
||||||
|
<input type="hidden" name="client_id" value="{{ .ClientID }}" />
|
||||||
|
<input type="hidden" name="signature" value="{{ .TokenHash }}" />
|
||||||
|
|
||||||
|
<dl class="billing">
|
||||||
|
<label>
|
||||||
|
<dt>Blog Title</dt>
|
||||||
|
<dd>
|
||||||
|
<input type="text" style="width: 100%; box-sizing: border-box;" name="alias" placeholder="Alias"{{ if .Alias }} value="{{.Alias}}"{{ end }} />
|
||||||
|
</dd>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<dt>Username</dt>
|
||||||
|
<dd>
|
||||||
|
<input type="text" name="username" style="width: 100%; box-sizing: border-box;" placeholder="Username" value="{{.Username}}" /><br />
|
||||||
|
{{if .Federation}}<p id="alias-site" class="demo">@<strong>your-username</strong>@{{.FriendlyHost}}</p>{{else}}<p id="alias-site" class="demo">{{.FriendlyHost}}/<strong>your-username</strong></p>{{end}}
|
||||||
|
</dd>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<dt>Email</dt>
|
||||||
|
<dd>
|
||||||
|
<input type="text" name="email" style="width: 100%; box-sizing: border-box;" placeholder="Email"{{ if .Email }} value="{{.Email}}"{{ end }} />
|
||||||
|
</dd>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<dt>Password</dt>
|
||||||
|
<dd>
|
||||||
|
<input type="password" name="password" style="width: 100%; box-sizing: border-box;" placeholder="Password" /><br />
|
||||||
|
</dd>
|
||||||
|
</label>
|
||||||
|
<dt>
|
||||||
|
<input type="submit" id="btn-login" value="Login" />
|
||||||
|
</dt>
|
||||||
|
</dl>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
function disableSubmit() {
|
||||||
|
var $btn = document.getElementById("btn-login");
|
||||||
|
$btn.value = "Logging in...";
|
||||||
|
$btn.disabled = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
BIN
static/img/sign_in_with_slack.png
Normal file
BIN
static/img/sign_in_with_slack.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.5 KiB |
BIN
static/img/sign_in_with_slack@2x.png
Normal file
BIN
static/img/sign_in_with_slack@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5 KiB |
Loading…
Add table
Reference in a new issue