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