Skip to content

Commit

Permalink
Add new login provider: Reddit
Browse files Browse the repository at this point in the history
It is now possible to sign into SteemWatch using Reddit.

Unexpected amount of work was necessary to implement this actually.

The OAuth2 package could not be used because Reddit is limiting HTTP requests by
User-Agent header and it was not possile to set custom header for that library.

Secondly, Reddit does not give us any user email, so the whole auth framework
had to be modified to allow for custom social links. But in case email address
is needed for anything in the future, Reddit users will be missing it.

Resolves #33
  • Loading branch information
tchap committed Aug 11, 2016
1 parent d9095b8 commit ce9a94a
Show file tree
Hide file tree
Showing 11 changed files with 330 additions and 28 deletions.
3 changes: 3 additions & 0 deletions config/config.go
Expand Up @@ -14,6 +14,9 @@ type Config struct {
FacebookClientId string `envconfig:"FACEBOOK_CLIENT_ID" required:"true"`
FacebookClientSecret string `envconfig:"FACEBOOK_CLIENT_SECRET" required:"true"`

RedditClientId string `envconfig:"REDDIT_CLIENT_ID" required:"true"`
RedditClientSecret string `envconfig:"REDDIT_CLIENT_SECRET" required:"true"`

GoogleClientId string `envconfig:"GOOGLE_CLIENT_ID" required:"true"`
GoogleClientSecret string `envconfig:"GOOGLE_CLIENT_SECRET" required:"true"`

Expand Down
2 changes: 1 addition & 1 deletion server/app/src/app.component.html
Expand Up @@ -10,7 +10,7 @@
<li><a [routerLink]="['/eventstream']">Event Stream</a></li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li><a [routerLink]="['/profile']">{{ctx.user.email}}</a></li>
<li><a [routerLink]="['/profile']">{{ctx.user.displayName}}</a></li>
<li><button class="btn btn-info btn-logout" (click)="logout()">Logout</button></li>
</ul>
</div>
Expand Down
22 changes: 20 additions & 2 deletions server/auth/profile.go
Expand Up @@ -4,10 +4,28 @@ import (
"github.com/tchap/steemwatch/server/users"
)

type SocialLink struct {
ServiceName string
UserKey string
UserName string
}

type UserProfile struct {
Email string
Email string
SocialLink *SocialLink
}

func (profile *UserProfile) AsUser() *users.User {
return &users.User{Email: profile.Email}
user := &users.User{Email: profile.Email}

if link := profile.SocialLink; link != nil {
user.SocialLinks = map[string]*users.SocialLink{
link.ServiceName: &users.SocialLink{
UserKey: link.UserKey,
UserName: link.UserName,
},
}
}

return user
}
206 changes: 206 additions & 0 deletions server/auth/reddit/authenticator.go
@@ -0,0 +1,206 @@
package reddit

import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"io"
"io/ioutil"
"net/http"
"net/url"
"strings"
"time"

"github.com/tchap/steemwatch/server/auth"

"github.com/labstack/echo"
"github.com/pkg/errors"
"golang.org/x/oauth2"
)

const StateCookieName = "reddit_oauth2_state"

const UserAgent = "SteemWatch"

type Authenticator struct {
config *oauth2.Config
forceSSL bool
}

func NewAuthenticator(clientID, clientSecret, redirectURL string, forceSSL bool) *Authenticator {
return &Authenticator{
config: &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: []string{
"identity",
},
Endpoint: oauth2.Endpoint{
AuthURL: "https://www.reddit.com/api/v1/authorize",
TokenURL: "https://www.reddit.com/api/v1/access_token",
},
},
forceSSL: forceSSL,
}
}

func (authenticator *Authenticator) Authenticate(ctx echo.Context) error {
// Generate random state.
state, err := generateState()
if err != nil {
return err
}

// Store the state in the state cookie.
cookie := &echo.Cookie{}
cookie.SetName(StateCookieName)
cookie.SetValue(state)
cookie.SetHTTPOnly(true)
cookie.SetSecure(authenticator.forceSSL)

ctx.SetCookie(cookie)

// Redirect to the consent page.
v := url.Values{
"client_id": {authenticator.config.ClientID},
"redirect_uri": {authenticator.config.RedirectURL},
"response_type": {"code"},
"scope": {"identity"},
"state": {state},
}
consentPageURL := authenticator.config.Endpoint.AuthURL + "?" + v.Encode()
return ctx.Redirect(http.StatusTemporaryRedirect, consentPageURL)
}

func (authenticator *Authenticator) Callback(ctx echo.Context) (*auth.UserProfile, error) {
// Get the OAuth2 state cookie.
stateCookie, err := ctx.Cookie(StateCookieName)
if err != nil {
return nil, errors.Wrap(err, "reddit: failed to get state cookie")
}

// Clear the cookie.
cookie := &echo.Cookie{}
cookie.SetName(StateCookieName)
cookie.SetValue("unset")
cookie.SetHTTPOnly(true)
cookie.SetSecure(authenticator.forceSSL)
cookie.SetExpires(time.Now().Add(-24 * time.Hour))

ctx.SetCookie(cookie)

// Make sure the query param matches the state cookie.
state := ctx.QueryParam("state")
if v := stateCookie.Value(); v != state {
return nil, errors.Errorf("reddit: state mismatch: %v != %v", v, state)
}

// Get the access token.
token, err := authenticator.getAccessToken(ctx.QueryParam("code"))
if err != nil {
return nil, err
}

// Get an authenticated HTTP client.
httpClient := authenticator.config.Client(oauth2.NoContext, token)

// Call Reddit API to get the current user's profile.
req, err := http.NewRequest("GET", "https://oauth.reddit.com/api/v1/me", nil)
if err != nil {
return nil, errors.Wrap(err, "reddit: failed to create profile request")
}
req.Header.Set("User-Agent", UserAgent)

res, err := httpClient.Do(req)
if err != nil {
return nil, errors.Wrap(err, "reddit: failed to get Reddit profile")
}
defer res.Body.Close()

// Read the response body.
body, err := ioutil.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return nil, errors.Wrap(err, "reddit: failed to read profile body")
}

// Unmarshal the response body.
var profile struct {
Name string `json:"name"`
}
if err := json.Unmarshal(body, &profile); err != nil {
return nil, errors.Wrap(err, "reddit: failed to unmarshal profile")
}

// At last, return the normalized profile.
return &auth.UserProfile{
SocialLink: &auth.SocialLink{
ServiceName: "reddit",
UserKey: profile.Name,
UserName: profile.Name,
},
}, nil
}

func (authenticator *Authenticator) getAccessToken(code string) (*oauth2.Token, error) {
config := authenticator.config

v := url.Values{
"client_id": {config.ClientID},
"client_secret": {config.ClientSecret},
"redirect_uri": {config.RedirectURL},
"grant_type": {"authorization_code"},
"code": {code},
"scope": {"identity"},
}

req, err := http.NewRequest("POST", config.Endpoint.TokenURL, strings.NewReader(v.Encode()))
if err != nil {
return nil, errors.Wrap(err, "reddit: failed to create token request")
}

req.Header.Set("User-Agent", UserAgent)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(config.ClientID, config.ClientSecret)
req.Close = true

res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, errors.Wrap(err, "reddit: failed to get access token")
}
defer res.Body.Close()

body, err := ioutil.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return nil, errors.Wrap(err, "reddit: cannot fetch token")
}

if code := res.StatusCode; code < 200 || code >= 300 {
return nil, errors.Wrapf(
err, "reddit: cannot fetch token\nResponse: %s", res.Status, body)
}

// Unmarshal the access token.
var tokenRaw struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &tokenRaw); err != nil {
return nil, errors.Wrap(err, "reddit: failed to unmarshal access token")
}

return &oauth2.Token{
AccessToken: tokenRaw.AccessToken,
TokenType: tokenRaw.TokenType,
Expiry: time.Now().Add(time.Duration(tokenRaw.ExpiresIn) * time.Second),
}, nil
}

func generateState() (string, error) {
raw := make([]byte, 258/8)
if _, err := rand.Read(raw); err != nil {
return "", errors.Wrap(err, "failed to generate OAuth2 state")
}
return base64.StdEncoding.EncodeToString(raw), nil
}
6 changes: 6 additions & 0 deletions server/routes/home/handler.go
Expand Up @@ -35,6 +35,12 @@ func (handler *Handler) HandlerFunc(ctx echo.Context) error {
templateCtx.Environment = string(handler.ctx.Env)
templateCtx.UserId = profile.Id
templateCtx.UserEmail = profile.Email
templateCtx.UserDisplayName = profile.Email

for k, v := range profile.SocialLinks {
templateCtx.UserDisplayName = v.UserName + "@" + k
break
}
}

return ctx.Render(http.StatusOK, templateName, templateCtx)
Expand Down
17 changes: 14 additions & 3 deletions server/server.go
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/tchap/steemwatch/server/auth/facebook"
"github.com/tchap/steemwatch/server/auth/github"
"github.com/tchap/steemwatch/server/auth/google"
"github.com/tchap/steemwatch/server/auth/reddit"
"github.com/tchap/steemwatch/server/context"
"github.com/tchap/steemwatch/server/db"
"github.com/tchap/steemwatch/server/routes/api/events/descendantpublished"
Expand Down Expand Up @@ -49,6 +50,7 @@ func Run(mongo *mgo.Database, cfg *config.Config) (*Context, error) {
serverCtx.Env = context.EnvironmentDevelopment
case "production":
serverCtx.Env = context.EnvironmentProduction
serverCtx.SSLEnabled = true
default:
return nil, errors.New("invalid environment: " + cfg.Env)
}
Expand Down Expand Up @@ -119,17 +121,26 @@ func Run(mongo *mgo.Database, cfg *config.Config) (*Context, error) {

facebookCallbackPath, _ := url.Parse("/auth/facebook/callback")
facebookCallback := serverCtx.CanonicalURL.ResolveReference(facebookCallbackPath).String()
facebookAuth := facebook.NewAuthenticator(cfg.FacebookClientId, cfg.FacebookClientSecret, facebookCallback)
facebookAuth := facebook.NewAuthenticator(
cfg.FacebookClientId, cfg.FacebookClientSecret, facebookCallback)
auth.Bind(serverCtx, e.Group("/auth/facebook"), facebookAuth)

redditCallbackPath, _ := url.Parse("/auth/reddit/callback")
redditCallback := serverCtx.CanonicalURL.ResolveReference(redditCallbackPath).String()
redditAuth := reddit.NewAuthenticator(
cfg.RedditClientId, cfg.RedditClientSecret, redditCallback, serverCtx.SSLEnabled)
auth.Bind(serverCtx, e.Group("/auth/reddit"), redditAuth)

googleCallbackPath, _ := url.Parse("/auth/google/callback")
googleCallback := serverCtx.CanonicalURL.ResolveReference(googleCallbackPath).String()
googleAuth := google.NewAuthenticator(cfg.GoogleClientId, cfg.GoogleClientSecret, googleCallback)
googleAuth := google.NewAuthenticator(
cfg.GoogleClientId, cfg.GoogleClientSecret, googleCallback)
auth.Bind(serverCtx, e.Group("/auth/google"), googleAuth)

githubCallbackPath, _ := url.Parse("/auth/github/callback")
githubCallback := serverCtx.CanonicalURL.ResolveReference(githubCallbackPath).String()
githubAuth := github.NewAuthenticator(cfg.GitHubClientId, cfg.GitHubClientSecret, githubCallback)
githubAuth := github.NewAuthenticator(
cfg.GitHubClientId, cfg.GitHubClientSecret, githubCallback)
auth.Bind(serverCtx, e.Group("/auth/github"), githubAuth)

// Public API
Expand Down

0 comments on commit ce9a94a

Please sign in to comment.