@@ -260,7 +260,7 @@ func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *ht | |||||
} | } | ||||
if err != nil { | if err != nil { | ||||
log.Error("toggle user suspended: %v", err) | 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)} | return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)} | ||||
} | } | ||||
@@ -70,7 +70,7 @@ type App struct { | |||||
cfg *config.Config | cfg *config.Config | ||||
cfgFile string | cfgFile string | ||||
keys *key.Keychain | keys *key.Keychain | ||||
sessionStore *sessions.CookieStore | |||||
sessionStore sessions.Store | |||||
formDecoder *schema.Decoder | formDecoder *schema.Decoder | ||||
timeline *localTimeline | timeline *localTimeline | ||||
@@ -101,6 +101,14 @@ func (app *App) SetKeys(k *key.Keychain) { | |||||
app.keys = k | 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 | // Apper is the interface for getting data into and out of a WriteFreely | ||||
// instance (or "App"). | // instance (or "App"). | ||||
// | // | ||||
@@ -92,6 +92,15 @@ type ( | |||||
LocalTimeline bool `ini:"local_timeline"` | LocalTimeline bool `ini:"local_timeline"` | ||||
UserInvites string `ini:"user_invites"` | 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 | // Defaults | ||||
DefaultVisibility string `ini:"default_visibility"` | DefaultVisibility string `ini:"default_visibility"` | ||||
} | } | ||||
@@ -11,8 +11,12 @@ | |||||
package writefreely | package writefreely | ||||
import ( | import ( | ||||
"context" | |||||
"crypto/rand" | |||||
"database/sql" | "database/sql" | ||||
"fmt" | "fmt" | ||||
"github.com/pkg/errors" | |||||
"math/big" | |||||
"net/http" | "net/http" | ||||
"strings" | "strings" | ||||
"time" | "time" | ||||
@@ -2453,6 +2457,59 @@ func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) { | |||||
return &t, nil | 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 | // DatabaseInitialized returns whether or not the current datastore has been | ||||
// initialized with the correct schema. | // initialized with the correct schema. | ||||
// Currently, it checks to see if the `users` table exists. | // 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) | log.Error("Couldn't insert into posts: %v", err) | ||||
return 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/nicksnyder/go-i18n v1.10.0 // indirect | ||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d | ||||
github.com/pelletier/go-toml v1.2.0 // indirect | 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/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/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect | ||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // 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/activity v0.1.2 | ||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible | github.com/writeas/go-strip-markdown v2.0.1+incompatible | ||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2 | 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/impart v1.1.0 | ||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 | github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 | ||||
github.com/writeas/nerds v1.0.0 | 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/saturday v1.7.1 | ||||
github.com/writeas/slug v1.2.0 | github.com/writeas/slug v1.2.0 | ||||
github.com/writeas/web-core v1.2.0 | github.com/writeas/web-core v1.2.0 | ||||
@@ -55,6 +53,7 @@ require ( | |||||
google.golang.org/appengine v1.4.0 // indirect | google.golang.org/appengine v1.4.0 // indirect | ||||
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect | gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect | ||||
gopkg.in/ini.v1 v1.41.0 | 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 | 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/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 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o= | ||||
github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA= | 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 h1:lYo1EH6CYyrFObQoA9RNWHVlpZA5iYL5Opxo7PYAnZE= | ||||
github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ= | github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ= | ||||
github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g= | 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= | 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 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo= | ||||
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= | 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 h1:ETU2VEl7TnT5bl7IvuKEzTDpplg5wzGYsOCAPhdoEIg= | ||||
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= | 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= | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= | ||||
@@ -73,7 +73,7 @@ type ( | |||||
type Handler struct { | type Handler struct { | ||||
errors *ErrorPages | errors *ErrorPages | ||||
sessionStore *sessions.CookieStore | |||||
sessionStore sessions.Store | |||||
app Apper | 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}}")), | 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}}")), | 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, | 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) | |||||
}) | |||||
} |