mirror of
https://github.com/writefreely/writefreely.git
synced 2024-11-15 00:41:06 +00:00
Added oauth handlers and tests with mocks. Part of T705.
This commit is contained in:
parent
e16ea3b419
commit
7a0863f71b
2
admin.go
2
admin.go
@ -260,7 +260,7 @@ func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *ht
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("toggle user suspended: %v", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v")}
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v", err)}
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)}
|
||||
}
|
||||
|
10
app.go
10
app.go
@ -70,7 +70,7 @@ type App struct {
|
||||
cfg *config.Config
|
||||
cfgFile string
|
||||
keys *key.Keychain
|
||||
sessionStore *sessions.CookieStore
|
||||
sessionStore sessions.Store
|
||||
formDecoder *schema.Decoder
|
||||
|
||||
timeline *localTimeline
|
||||
@ -101,6 +101,14 @@ func (app *App) SetKeys(k *key.Keychain) {
|
||||
app.keys = k
|
||||
}
|
||||
|
||||
func (app *App) SessionStore() sessions.Store {
|
||||
return app.sessionStore
|
||||
}
|
||||
|
||||
func (app *App) SetSessionStore(s sessions.Store) {
|
||||
app.sessionStore = s
|
||||
}
|
||||
|
||||
// Apper is the interface for getting data into and out of a WriteFreely
|
||||
// instance (or "App").
|
||||
//
|
||||
|
@ -92,6 +92,15 @@ type (
|
||||
LocalTimeline bool `ini:"local_timeline"`
|
||||
UserInvites string `ini:"user_invites"`
|
||||
|
||||
// OAuth
|
||||
EnableOAuth bool `ini:"enable_oauth"`
|
||||
OAuthProviderAuthLocation string `ini:"oauth_auth_location"`
|
||||
OAuthProviderTokenLocation string `ini:"oauth_token_location"`
|
||||
OAuthProviderInspectLocation string `ini:"oauth_inspect_location"`
|
||||
OAuthClientCallbackLocation string `ini:"oauth_callback_location"`
|
||||
OAuthClientID string `ini:"oauth_client_id"`
|
||||
OAuthClientSecret string `ini:"oauth_client_secret"`
|
||||
|
||||
// Defaults
|
||||
DefaultVisibility string `ini:"default_visibility"`
|
||||
}
|
||||
|
80
database.go
80
database.go
@ -11,8 +11,12 @@
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/pkg/errors"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@ -2453,6 +2457,59 @@ func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) {
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (db *datastore) GenerateOAuthState(ctx context.Context) (string, error) {
|
||||
state, err := randString(24)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = db.ExecContext(ctx, "INSERT INTO oauth_client_state (state, used, created_at) VALUES (?, FALSE, NOW())", state)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to record oauth client state: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (db *datastore) ValidateOAuthState(ctx context.Context, state string) error {
|
||||
res, err := db.ExecContext(ctx, "UPDATE oauth_client_state SET used = TRUE WHERE state = ?", state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected != 1 {
|
||||
return fmt.Errorf("state not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *datastore) RecordRemoteUserID(ctx context.Context, localUserID, remoteUserID int64) error {
|
||||
var err error
|
||||
if db.driverName == driverSQLite {
|
||||
_, err = db.ExecContext(ctx, "INSERT OR REPLACE INTO users_oauth (user_id, remote_user_id) VALUES (?, ?)", localUserID, remoteUserID)
|
||||
} else {
|
||||
_, err = db.ExecContext(ctx, "INSERT INTO users_oauth (user_id, remote_user_id) VALUES (?, ?) "+db.upsert("user_id"), localUserID, remoteUserID)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Unable to INSERT users_oauth for '%d': %v", localUserID, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// GetIDForRemoteUser returns a user ID associated with a remote user ID.
|
||||
func (db *datastore) GetIDForRemoteUser(ctx context.Context, remoteUserID int64) (int64, error) {
|
||||
var userID int64 = -1
|
||||
err := db.
|
||||
QueryRowContext(ctx, "SELECT user_id FROM users_oauth WHERE remote_user_id = ?", remoteUserID).
|
||||
Scan(&userID)
|
||||
// Not finding a record is OK.
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return -1, err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// DatabaseInitialized returns whether or not the current datastore has been
|
||||
// initialized with the correct schema.
|
||||
// Currently, it checks to see if the `users` table exists.
|
||||
@ -2483,3 +2540,26 @@ func handleFailedPostInsert(err error) error {
|
||||
log.Error("Couldn't insert into posts: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
func randString(length int) (string, error) {
|
||||
// every printable character on a US keyboard
|
||||
charset := []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
|
||||
out := make([]rune, length)
|
||||
|
||||
setLen := big.NewInt(int64(len(charset)))
|
||||
for idx := 0; idx < length; idx++ {
|
||||
offset, err := rand.Int(rand.Reader, setLen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !offset.IsUint64() {
|
||||
// this should (in theory) never happen
|
||||
return "", errors.Errorf("Non-Uint64 offset returned from rand.Int")
|
||||
}
|
||||
|
||||
out[idx] = charset[offset.Uint64()]
|
||||
}
|
||||
|
||||
return string(out), nil
|
||||
}
|
||||
|
9
go.mod
9
go.mod
@ -29,12 +29,11 @@ require (
|
||||
github.com/nicksnyder/go-i18n v1.10.0 // indirect
|
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
|
||||
github.com/pelletier/go-toml v1.2.0 // indirect
|
||||
github.com/pkg/errors v0.8.1 // indirect
|
||||
github.com/pkg/errors v0.8.1
|
||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
|
||||
github.com/stretchr/testify v1.3.0 // indirect
|
||||
github.com/stretchr/testify v1.3.0
|
||||
github.com/writeas/activity v0.1.2
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible
|
||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2
|
||||
@ -42,7 +41,6 @@ require (
|
||||
github.com/writeas/impart v1.1.0
|
||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219
|
||||
github.com/writeas/nerds v1.0.0
|
||||
github.com/writeas/openssl-go v1.0.0 // indirect
|
||||
github.com/writeas/saturday v1.7.1
|
||||
github.com/writeas/slug v1.2.0
|
||||
github.com/writeas/web-core v1.2.0
|
||||
@ -55,6 +53,7 @@ require (
|
||||
google.golang.org/appengine v1.4.0 // indirect
|
||||
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect
|
||||
gopkg.in/ini.v1 v1.41.0
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 // indirect
|
||||
gopkg.in/yaml.v2 v2.2.2 // indirect
|
||||
)
|
||||
|
||||
go 1.13
|
||||
|
2
go.sum
2
go.sum
@ -129,6 +129,7 @@ github.com/writeas/nerds v1.0.0 h1:ZzRcCN+Sr3MWID7o/x1cr1ZbLvdpej9Y1/Ho+JKlqxo=
|
||||
github.com/writeas/nerds v1.0.0/go.mod h1:Gn2bHy1EwRcpXeB7ZhVmuUwiweK0e+JllNf66gvNLdU=
|
||||
github.com/writeas/openssl-go v1.0.0 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o=
|
||||
github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA=
|
||||
github.com/writeas/saturday v1.6.0/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||
github.com/writeas/saturday v1.7.1 h1:lYo1EH6CYyrFObQoA9RNWHVlpZA5iYL5Opxo7PYAnZE=
|
||||
github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||
github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g=
|
||||
@ -141,6 +142,7 @@ github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAv
|
||||
github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
|
||||
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo=
|
||||
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f h1:ETU2VEl7TnT5bl7IvuKEzTDpplg5wzGYsOCAPhdoEIg=
|
||||
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
|
@ -73,7 +73,7 @@ type (
|
||||
|
||||
type Handler struct {
|
||||
errors *ErrorPages
|
||||
sessionStore *sessions.CookieStore
|
||||
sessionStore sessions.Store
|
||||
app Apper
|
||||
}
|
||||
|
||||
@ -96,7 +96,7 @@ func NewHandler(apper Apper) *Handler {
|
||||
InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>500</title></head><body><p>Internal server error.</p></body></html>{{end}}")),
|
||||
Blank: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>{{.Title}}</title></head><body><p>{{.Content}}</p></body></html>{{end}}")),
|
||||
},
|
||||
sessionStore: apper.App().sessionStore,
|
||||
sessionStore: apper.App().SessionStore(),
|
||||
app: apper,
|
||||
}
|
||||
|
||||
|
18
main_test.go
Normal file
18
main_test.go
Normal file
@ -0,0 +1,18 @@
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"math/rand"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestMain provides testing infrastructure within this package.
|
||||
func TestMain(m *testing.M) {
|
||||
rand.Seed(time.Now().UTC().UnixNano())
|
||||
|
||||
gob.Register(&User{})
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
252
oauth.go
Normal file
252
oauth.go
Normal file
@ -0,0 +1,252 @@
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/guregu/null/zero"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/auth"
|
||||
"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"`
|
||||
}
|
||||
|
||||
// InspectResponse contains data returned when an access token is inspected.
|
||||
type InspectResponse struct {
|
||||
ClientID string `json:"client_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
GenerateOAuthState(context.Context) (string, error)
|
||||
ValidateOAuthState(context.Context, string) error
|
||||
GetIDForRemoteUser(context.Context, int64) (int64, error)
|
||||
CreateUser(*config.Config, *User, string) error
|
||||
RecordRemoteUserID(context.Context, int64, int64) error
|
||||
GetUserForAuthByID(int64) (*User, error)
|
||||
}
|
||||
|
||||
type HttpClient interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
type oauthHandler struct {
|
||||
HttpClient HttpClient
|
||||
}
|
||||
|
||||
// buildAuthURL returns a URL used to initiate authentication.
|
||||
func buildAuthURL(app OAuthDatastoreProvider, ctx context.Context, clientID, authLocation, callbackURL string) (string, error) {
|
||||
state, err := app.DB().GenerateOAuthState(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
u, err := url.Parse(authLocation)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("client_id", clientID)
|
||||
q.Set("redirect_uri", callbackURL)
|
||||
q.Set("response_type", "code")
|
||||
q.Set("state", state)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (h oauthHandler) viewOauthInit(app OAuthDatastoreProvider, w http.ResponseWriter, r *http.Request) error {
|
||||
location, err := buildAuthURL(app, r.Context(), app.Config().App.OAuthClientID, app.Config().App.OAuthProviderAuthLocation, app.Config().App.OAuthClientCallbackLocation)
|
||||
if err != nil {
|
||||
log.ErrorLog.Println(err)
|
||||
return impart.HTTPError{Status: http.StatusInternalServerError, Message: "Could not prepare OAuth redirect URL."}
|
||||
}
|
||||
http.Redirect(w, r, location, http.StatusTemporaryRedirect)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h oauthHandler) viewOauthCallback(app OAuthDatastoreProvider, w http.ResponseWriter, r *http.Request) error {
|
||||
ctx := r.Context()
|
||||
|
||||
code := r.FormValue("code")
|
||||
state := r.FormValue("state")
|
||||
|
||||
err := app.DB().ValidateOAuthState(ctx, state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tokenResponse, err := h.exchangeOauthCode(app, ctx, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Now that we have the access token, let's use it real quick to make sur
|
||||
// it really really works.
|
||||
tokenInfo, err := h.inspectOauthAccessToken(app, ctx, tokenResponse.AccessToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
localUserID, err := app.DB().GetIDForRemoteUser(ctx, tokenInfo.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if localUserID == -1 {
|
||||
// We don't have, nor do we want, the password from the origin, so we
|
||||
//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, err := randString(14)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hashedPass, err := auth.HashPass([]byte(randPass))
|
||||
if err != nil {
|
||||
log.ErrorLog.Println(err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
|
||||
}
|
||||
newUser := &User{
|
||||
Username: tokenInfo.Username,
|
||||
HashedPass: hashedPass,
|
||||
HasPass: true,
|
||||
Email: zero.NewString("", tokenInfo.Email != ""),
|
||||
Created: time.Now().Truncate(time.Second).UTC(),
|
||||
}
|
||||
|
||||
err = app.DB().CreateUser(app.Config(), newUser, newUser.Username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = app.DB().RecordRemoteUserID(ctx, newUser.ID, tokenInfo.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return loginOrFail(app, w, r, newUser)
|
||||
}
|
||||
|
||||
user, err := app.DB().GetUserForAuthByID(localUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return loginOrFail(app, w, r, user)
|
||||
}
|
||||
|
||||
func (h oauthHandler) exchangeOauthCode(app OAuthDatastoreProvider, ctx context.Context, code string) (*TokenResponse, error) {
|
||||
form := url.Values{}
|
||||
form.Add("grant_type", "authorization_code")
|
||||
form.Add("redirect_uri", app.Config().App.OAuthClientCallbackLocation)
|
||||
form.Add("code", code)
|
||||
req, err := http.NewRequest("POST", app.Config().App.OAuthProviderTokenLocation, 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(app.Config().App.OAuthClientID, app.Config().App.OAuthClientSecret)
|
||||
|
||||
resp, err := h.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Nick: I like using limited readers to reduce the risk of an endpoint
|
||||
// being broken or compromised.
|
||||
lr := io.LimitReader(resp.Body, tokenRequestMaxLen)
|
||||
body, err := ioutil.ReadAll(lr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var tokenResponse TokenResponse
|
||||
err = json.Unmarshal(body, &tokenResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tokenResponse, nil
|
||||
}
|
||||
|
||||
func (h oauthHandler) inspectOauthAccessToken(app OAuthDatastoreProvider, ctx context.Context, accessToken string) (*InspectResponse, error) {
|
||||
req, err := http.NewRequest("GET", app.Config().App.OAuthProviderInspectLocation, 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 := h.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Nick: I like using limited readers to reduce the risk of an endpoint
|
||||
// being broken or compromised.
|
||||
lr := io.LimitReader(resp.Body, infoRequestMaxLen)
|
||||
body, err := ioutil.ReadAll(lr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var inspectResponse InspectResponse
|
||||
err = json.Unmarshal(body, &inspectResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inspectResponse, nil
|
||||
}
|
||||
|
||||
func loginOrFail(app OAuthDatastoreProvider, w http.ResponseWriter, r *http.Request, user *User) error {
|
||||
session, err := app.SessionStore().Get(r, cookieName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session.Values[cookieUserVal] = user.Cookie()
|
||||
if err = session.Save(r, w); err != nil {
|
||||
return err
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||
return nil
|
||||
}
|
10
oauth/state.go
Normal file
10
oauth/state.go
Normal file
@ -0,0 +1,10 @@
|
||||
package oauth
|
||||
|
||||
import "context"
|
||||
|
||||
// ClientStateStore provides state management used by the OAuth client.
|
||||
type ClientStateStore interface {
|
||||
Generate(ctx context.Context) (string, error)
|
||||
Validate(ctx context.Context, state string) error
|
||||
}
|
||||
|
198
oauth_test.go
Normal file
198
oauth_test.go
Normal file
@ -0,0 +1,198 @@
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type MockOAuthDatastoreProvider struct {
|
||||
DoDB func() OAuthDatastore
|
||||
DoConfig func() *config.Config
|
||||
DoSessionStore func() sessions.Store
|
||||
}
|
||||
|
||||
type MockOAuthDatastore struct {
|
||||
DoGenerateOAuthState func(ctx context.Context) (string, error)
|
||||
DoValidateOAuthState func(context.Context, string) error
|
||||
DoGetIDForRemoteUser func(context.Context, int64) (int64, error)
|
||||
DoCreateUser func(*config.Config, *User, string) error
|
||||
DoRecordRemoteUserID func(context.Context, int64, int64) error
|
||||
DoGetUserForAuthByID func(int64) (*User, error)
|
||||
}
|
||||
|
||||
type StringReadCloser struct {
|
||||
*strings.Reader
|
||||
}
|
||||
|
||||
func (src *StringReadCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type MockHTTPClient struct {
|
||||
DoDo func(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
|
||||
if m.DoDo != nil {
|
||||
return m.DoDo(req)
|
||||
}
|
||||
return &http.Response{}, nil
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastoreProvider) SessionStore() sessions.Store {
|
||||
if m.DoSessionStore != nil {
|
||||
return m.DoSessionStore()
|
||||
}
|
||||
return sessions.NewCookieStore([]byte("secret-key"))
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastoreProvider) DB() OAuthDatastore {
|
||||
if m.DoDB != nil {
|
||||
return m.DoDB()
|
||||
}
|
||||
return &MockOAuthDatastore{}
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastoreProvider) Config() *config.Config {
|
||||
if m.DoConfig != nil {
|
||||
return m.DoConfig()
|
||||
}
|
||||
cfg := config.New()
|
||||
cfg.UseSQLite(true)
|
||||
cfg.App.EnableOAuth = true
|
||||
cfg.App.OAuthProviderAuthLocation = "https://write.as/oauth/login"
|
||||
cfg.App.OAuthProviderTokenLocation = "https://write.as/oauth/token"
|
||||
cfg.App.OAuthProviderInspectLocation = "https://write.as/oauth/inspect"
|
||||
cfg.App.OAuthClientCallbackLocation = "http://localhost/oauth/callback"
|
||||
cfg.App.OAuthClientID = "development"
|
||||
cfg.App.OAuthClientSecret = "development"
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) ValidateOAuthState(ctx context.Context, state string) error {
|
||||
if m.DoValidateOAuthState != nil {
|
||||
return m.DoValidateOAuthState(ctx, state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) GetIDForRemoteUser(ctx context.Context, remoteUserID int64) (int64, error) {
|
||||
if m.DoGetIDForRemoteUser != nil {
|
||||
return m.DoGetIDForRemoteUser(ctx, remoteUserID)
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) CreateUser(cfg *config.Config, u *User, username string) error {
|
||||
if m.DoCreateUser != nil {
|
||||
return m.DoCreateUser(cfg, u, username)
|
||||
}
|
||||
u.ID = 1
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) RecordRemoteUserID(ctx context.Context, localUserID int64, remoteUserID int64) error {
|
||||
if m.DoRecordRemoteUserID != nil {
|
||||
return m.DoRecordRemoteUserID(ctx, localUserID, remoteUserID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) GetUserForAuthByID(userID int64) (*User, error) {
|
||||
if m.DoGetUserForAuthByID != nil {
|
||||
return m.DoGetUserForAuthByID(userID)
|
||||
}
|
||||
user := &User{
|
||||
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) GenerateOAuthState(ctx context.Context) (string, error) {
|
||||
if m.DoGenerateOAuthState != nil {
|
||||
return m.DoGenerateOAuthState(ctx)
|
||||
}
|
||||
return randString(14)
|
||||
}
|
||||
|
||||
func TestViewOauthInit(t *testing.T) {
|
||||
h := oauthHandler{}
|
||||
t.Run("success", func(t *testing.T) {
|
||||
app := &MockOAuthDatastoreProvider{}
|
||||
req, err := http.NewRequest("GET", "/oauth/client", nil)
|
||||
assert.NoError(t, err)
|
||||
rr := httptest.NewRecorder()
|
||||
err = h.viewOauthInit(app, rr, req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, rr.Code)
|
||||
locURI, err := url.Parse(rr.Header().Get("Location"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "/oauth/login", locURI.Path)
|
||||
assert.Equal(t, "development", locURI.Query().Get("client_id"))
|
||||
assert.Equal(t, "http://localhost/oauth/callback", locURI.Query().Get("redirect_uri"))
|
||||
assert.Equal(t, "code", locURI.Query().Get("response_type"))
|
||||
assert.NotEmpty(t, locURI.Query().Get("state"))
|
||||
})
|
||||
|
||||
t.Run("state failure", func(t *testing.T) {
|
||||
app := &MockOAuthDatastoreProvider{
|
||||
DoDB: func() OAuthDatastore {
|
||||
return &MockOAuthDatastore{
|
||||
DoGenerateOAuthState: func(ctx context.Context) (string, error) {
|
||||
return "", fmt.Errorf("pretend unable to write state error")
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
req, err := http.NewRequest("GET", "/oauth/client", nil)
|
||||
assert.NoError(t, err)
|
||||
rr := httptest.NewRecorder()
|
||||
err = h.viewOauthInit(app, rr, req)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, impart.HTTPError{Status: http.StatusInternalServerError, Message: "Could not prepare OAuth redirect URL."}, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestViewOauthCallback(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
app := &MockOAuthDatastoreProvider{}
|
||||
h := oauthHandler{
|
||||
HttpClient: &MockHTTPClient{
|
||||
DoDo: func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.String() {
|
||||
case "https://write.as/oauth/token":
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: &StringReadCloser{strings.NewReader(`{"access_token": "access_token", "expires_in": 1000, "refresh_token": "refresh_token", "token_type": "access"}`)},
|
||||
}, nil
|
||||
case "https://write.as/oauth/inspect":
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: &StringReadCloser{strings.NewReader(`{"client_id": "development", "user_id": 1, "expires_at": "2019-12-19T11:42:01Z", "username": "nick", "email": "nick@testing.write.as"}`)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
req, err := http.NewRequest("GET", "/oauth/callback", nil)
|
||||
assert.NoError(t, err)
|
||||
rr := httptest.NewRecorder()
|
||||
err = h.viewOauthCallback(app, rr, req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, rr.Code)
|
||||
|
||||
})
|
||||
}
|
Loading…
Reference in New Issue
Block a user