1
0
Fork 0
mirror of https://github.com/miniflux/v2.git synced 2025-08-01 17:38:37 +00:00

Add OAuth2 authentication

This commit is contained in:
Frédéric Guillot 2017-11-22 22:22:33 -08:00
parent 9877051f12
commit cc6d272eb7
351 changed files with 81664 additions and 55 deletions

View file

@ -6,11 +6,12 @@ package middleware
import (
"context"
"log"
"net/http"
"github.com/miniflux/miniflux2/model"
"github.com/miniflux/miniflux2/server/route"
"github.com/miniflux/miniflux2/storage"
"log"
"net/http"
"github.com/gorilla/mux"
)
@ -45,7 +46,7 @@ func (s *SessionMiddleware) Handler(next http.Handler) http.Handler {
func (s *SessionMiddleware) isPublicRoute(r *http.Request) bool {
route := mux.CurrentRoute(r)
switch route.GetName() {
case "login", "checkLogin", "stylesheet", "javascript":
case "login", "checkLogin", "stylesheet", "javascript", "oauth2Redirect", "oauth2Callback":
return true
default:
return false

70
server/oauth2/google.go Normal file
View file

@ -0,0 +1,70 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package oauth2
import (
"context"
"encoding/json"
"fmt"
"golang.org/x/oauth2"
)
type googleProfile struct {
Sub string `json:"sub"`
Email string `json:"email"`
}
type googleProvider struct {
clientID string
clientSecret string
redirectURL string
}
func (g googleProvider) GetRedirectURL(state string) string {
return g.config().AuthCodeURL(state)
}
func (g googleProvider) GetProfile(code string) (*Profile, error) {
conf := g.config()
ctx := context.Background()
token, err := conf.Exchange(ctx, code)
if err != nil {
return nil, err
}
client := conf.Client(ctx, token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var user googleProfile
decoder := json.NewDecoder(resp.Body)
if err := decoder.Decode(&user); err != nil {
return nil, fmt.Errorf("unable to unserialize google profile: %v", err)
}
profile := &Profile{Key: "google_id", ID: user.Sub, Username: user.Email}
return profile, nil
}
func (g googleProvider) config() *oauth2.Config {
return &oauth2.Config{
RedirectURL: g.redirectURL,
ClientID: g.clientID,
ClientSecret: g.clientSecret,
Scopes: []string{"email"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://accounts.google.com/o/oauth2/auth",
TokenURL: "https://accounts.google.com/o/oauth2/token",
},
}
}
func newGoogleProvider(clientID, clientSecret, redirectURL string) *googleProvider {
return &googleProvider{clientID: clientID, clientSecret: clientSecret, redirectURL: redirectURL}
}

33
server/oauth2/manager.go Normal file
View file

@ -0,0 +1,33 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package oauth2
import "errors"
// Manager handles OAuth2 providers.
type Manager struct {
providers map[string]Provider
}
// Provider returns the given provider.
func (m *Manager) Provider(name string) (Provider, error) {
if provider, found := m.providers[name]; found {
return provider, nil
}
return nil, errors.New("oauth2 provider not found")
}
// AddProvider add a new OAuth2 provider.
func (m *Manager) AddProvider(name string, provider Provider) {
m.providers[name] = provider
}
// NewManager returns a new Manager.
func NewManager(clientID, clientSecret, redirectURL string) *Manager {
m := &Manager{providers: make(map[string]Provider)}
m.AddProvider("google", newGoogleProvider(clientID, clientSecret, redirectURL))
return m
}

12
server/oauth2/profile.go Normal file
View file

@ -0,0 +1,12 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package oauth2
// Profile is the OAuth2 user profile.
type Profile struct {
Key string
ID string
Username string
}

11
server/oauth2/provider.go Normal file
View file

@ -0,0 +1,11 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package oauth2
// Provider is an interface for OAuth2 providers.
type Provider interface {
GetRedirectURL(state string) string
GetProfile(code string) (*Profile, error)
}

View file

@ -29,7 +29,7 @@ func getRoutes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Han
templateEngine := template.NewEngine(cfg, router, translator)
apiController := api_controller.NewController(store, feedHandler)
uiController := ui_controller.NewController(store, pool, feedHandler, opml.NewHandler(store))
uiController := ui_controller.NewController(cfg, store, pool, feedHandler, opml.NewHandler(store))
apiHandler := core.NewHandler(store, router, templateEngine, translator, middleware.NewMiddlewareChain(
middleware.NewBasicAuthMiddleware(store).Handler,
@ -124,6 +124,9 @@ func getRoutes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Han
router.Handle("/import", uiHandler.Use(uiController.Import)).Name("import").Methods("GET")
router.Handle("/upload", uiHandler.Use(uiController.UploadOPML)).Name("uploadOPML").Methods("POST")
router.Handle("/oauth2/{provider}/redirect", uiHandler.Use(uiController.OAuth2Redirect)).Name("oauth2Redirect").Methods("GET")
router.Handle("/oauth2/{provider}/callback", uiHandler.Use(uiController.OAuth2Callback)).Name("oauth2Callback").Methods("GET")
router.Handle("/login", uiHandler.Use(uiController.CheckLogin)).Name("checkLogin").Methods("POST")
router.Handle("/logout", uiHandler.Use(uiController.Logout)).Name("logout").Methods("GET")
router.Handle("/", uiHandler.Use(uiController.ShowLoginPage)).Name("login").Methods("GET")

View file

@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2017-11-22 15:58:44.918764081 -0800 PST m=+0.003855762
// 2017-11-22 22:11:44.595540312 -0800 PST m=+0.009225645
package static

File diff suppressed because one or more lines are too long

View file

@ -130,6 +130,7 @@ a:hover {
padding-right: 15px;
line-height: normal;
border: none;
font-size: 1.0em;
}
.page-header ul {

View file

@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2017-11-22 15:58:44.924790514 -0800 PST m=+0.009882195
// 2017-11-22 22:11:44.598697812 -0800 PST m=+0.012383145
package static

View file

@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2017-11-22 15:58:44.944002944 -0800 PST m=+0.029094625
// 2017-11-22 22:11:44.609659332 -0800 PST m=+0.023344665
package template

View file

@ -19,5 +19,10 @@
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Sign in" }}</button>
</div>
</form>
{{ if hasOAuth2Provider "google" }}
<div class="oauth2">
<a href="{{ route "oauth2Redirect" "provider" "google" }}">{{ t "Sign in with Google" }}</a>
</div>
{{ end }}
</section>
{{ end }}

View file

@ -37,6 +37,9 @@ func (e *Engine) parseAll() {
"baseURL": func() string {
return e.cfg.Get("BASE_URL", config.DefaultBaseURL)
},
"hasOAuth2Provider": func(provider string) bool {
return e.cfg.Get("OAUTH2_PROVIDER", "") == provider
},
"route": func(name string, args ...interface{}) string {
return route.GetRoute(e.router, name, args...)
},

View file

@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2017-11-22 15:58:44.928001659 -0800 PST m=+0.013093340
// 2017-11-22 22:11:44.601583424 -0800 PST m=+0.015268757
package template
@ -796,6 +796,11 @@ var templateViewsMap = map[string]string{
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Sign in" }}</button>
</div>
</form>
{{ if hasOAuth2Provider "google" }}
<div class="oauth2">
<a href="{{ route "oauth2Redirect" "provider" "google" }}">{{ t "Sign in with Google" }}</a>
</div>
{{ end }}
</section>
{{ end }}
`,
@ -1045,7 +1050,7 @@ var templateViewsMapChecksums = map[string]string{
"history": "947603cbde888516e62925f5d08fb0b13d930623d3ee4c690dbc22612fdda75e",
"import": "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f",
"integrations": "c485d6d9ed996635e55e73320610e6bcb01a41c1153e8e739ae2294b0b14b243",
"login": "568f2f69f248048f3e55e9bbc719077a74ae23fe18f237aa40e3de37e97b7a41",
"login": "04f3ce79bfa5753f69e0d956c2a8999c0da549c7925634a3e8134975da0b0e0f",
"sessions": "878dbe8f8ea783b44130c495814179519fa5c3aa2666ac87508f94d58dd008bf",
"settings": "a972fb5767fd32522648149880e40607ed8bbed7a389038bbab6b08539ac2893",
"unread": "b6f9be1a72188947c75a6fdcac6ff7878db7745f9efa46318e0433102892a722",

View file

@ -5,6 +5,7 @@
package controller
import (
"github.com/miniflux/miniflux2/config"
"github.com/miniflux/miniflux2/model"
"github.com/miniflux/miniflux2/reader/feed"
"github.com/miniflux/miniflux2/reader/opml"
@ -25,6 +26,7 @@ func (t tplParams) Merge(d tplParams) tplParams {
// Controller contains all HTTP handlers for the user interface.
type Controller struct {
cfg *config.Config
store *storage.Storage
pool *scheduler.WorkerPool
feedHandler *feed.Handler
@ -51,8 +53,9 @@ func (c *Controller) getCommonTemplateArgs(ctx *core.Context) (tplParams, error)
}
// NewController returns a new Controller.
func NewController(store *storage.Storage, pool *scheduler.WorkerPool, feedHandler *feed.Handler, opmlHandler *opml.Handler) *Controller {
func NewController(cfg *config.Config, store *storage.Storage, pool *scheduler.WorkerPool, feedHandler *feed.Handler, opmlHandler *opml.Handler) *Controller {
return &Controller{
cfg: cfg,
store: store,
pool: pool,
feedHandler: feedHandler,

View file

@ -5,15 +5,17 @@
package controller
import (
"github.com/miniflux/miniflux2/server/core"
"github.com/miniflux/miniflux2/server/ui/form"
"log"
"net/http"
"time"
"github.com/miniflux/miniflux2/server/core"
"github.com/miniflux/miniflux2/server/ui/form"
"github.com/tomasen/realip"
)
// ShowLoginPage shows the login form.
func (c *Controller) ShowLoginPage(ctx *core.Context, request *core.Request, response *core.Response) {
if ctx.IsAuthenticated() {
response.Redirect(ctx.Route("unread"))
@ -25,6 +27,7 @@ func (c *Controller) ShowLoginPage(ctx *core.Context, request *core.Request, res
})
}
// CheckLogin validates the username/password and redirects the user to the unread page.
func (c *Controller) CheckLogin(ctx *core.Context, request *core.Request, response *core.Response) {
authForm := form.NewAuthForm(request.Request())
tplParams := tplParams{
@ -49,6 +52,7 @@ func (c *Controller) CheckLogin(ctx *core.Context, request *core.Request, respon
request.Request().UserAgent(),
realip.RealIP(request.Request()),
)
if err != nil {
response.HTML().ServerError(err)
return
@ -68,6 +72,7 @@ func (c *Controller) CheckLogin(ctx *core.Context, request *core.Request, respon
response.Redirect(ctx.Route("unread"))
}
// Logout destroy the session and redirects the user to the login page.
func (c *Controller) Logout(ctx *core.Context, request *core.Request, response *core.Response) {
user := ctx.LoggedUser()

View file

@ -0,0 +1,123 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package controller
import (
"log"
"net/http"
"github.com/miniflux/miniflux2/config"
"github.com/miniflux/miniflux2/model"
"github.com/miniflux/miniflux2/server/core"
"github.com/miniflux/miniflux2/server/oauth2"
"github.com/tomasen/realip"
)
// OAuth2Redirect redirects the user to the consent page to ask for permission.
func (c *Controller) OAuth2Redirect(ctx *core.Context, request *core.Request, response *core.Response) {
provider := request.StringParam("provider", "")
if provider == "" {
log.Println("[OAuth2] Invalid or missing provider")
response.Redirect(ctx.Route("login"))
return
}
authProvider, err := getOAuth2Manager(c.cfg).Provider(provider)
if err != nil {
log.Println("[OAuth2]", err)
response.Redirect(ctx.Route("login"))
return
}
response.Redirect(authProvider.GetRedirectURL(ctx.CsrfToken()))
}
// OAuth2Callback receives the authorization code and create a new session.
func (c *Controller) OAuth2Callback(ctx *core.Context, request *core.Request, response *core.Response) {
provider := request.StringParam("provider", "")
if provider == "" {
log.Println("[OAuth2] Invalid or missing provider")
response.Redirect(ctx.Route("login"))
return
}
code := request.QueryStringParam("code", "")
if code == "" {
log.Println("[OAuth2] No code received on callback")
response.Redirect(ctx.Route("login"))
return
}
state := request.QueryStringParam("state", "")
if state != ctx.CsrfToken() {
log.Println("[OAuth2] Invalid state value")
response.Redirect(ctx.Route("login"))
return
}
authProvider, err := getOAuth2Manager(c.cfg).Provider(provider)
if err != nil {
log.Println("[OAuth2]", err)
response.Redirect(ctx.Route("login"))
return
}
profile, err := authProvider.GetProfile(code)
if err != nil {
log.Println("[OAuth2]", err)
response.Redirect(ctx.Route("login"))
return
}
user, err := c.store.GetUserByExtraField(profile.Key, profile.ID)
if err != nil {
response.HTML().ServerError(err)
return
}
if user == nil {
user = model.NewUser()
user.Username = profile.Username
user.IsAdmin = false
user.Extra[profile.Key] = profile.ID
if err := c.store.CreateUser(user); err != nil {
response.HTML().ServerError(err)
return
}
}
sessionToken, err := c.store.CreateSession(
user.Username,
request.Request().UserAgent(),
realip.RealIP(request.Request()),
)
if err != nil {
response.HTML().ServerError(err)
return
}
log.Printf("[UI:OAuth2Callback] username=%s just logged in\n", user.Username)
cookie := &http.Cookie{
Name: "sessionID",
Value: sessionToken,
Path: "/",
Secure: request.IsHTTPS(),
HttpOnly: true,
}
response.SetCookie(cookie)
response.Redirect(ctx.Route("unread"))
}
func getOAuth2Manager(cfg *config.Config) *oauth2.Manager {
return oauth2.NewManager(
cfg.Get("OAUTH2_CLIENT_ID", ""),
cfg.Get("OAUTH2_CLIENT_SECRET", ""),
cfg.Get("OAUTH2_REDIRECT_URL", ""),
)
}