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:
parent
9877051f12
commit
cc6d272eb7
351 changed files with 81664 additions and 55 deletions
|
@ -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
70
server/oauth2/google.go
Normal 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
33
server/oauth2/manager.go
Normal 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
12
server/oauth2/profile.go
Normal 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
11
server/oauth2/provider.go
Normal 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)
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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
|
@ -130,6 +130,7 @@ a:hover {
|
|||
padding-right: 15px;
|
||||
line-height: normal;
|
||||
border: none;
|
||||
font-size: 1.0em;
|
||||
}
|
||||
|
||||
.page-header ul {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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...)
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
123
server/ui/controller/oauth2.go
Normal file
123
server/ui/controller/oauth2.go
Normal 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", ""),
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue