Co-authored-by: Sebastian Kempken <sebastian@kempken.io> Co-authored-by: Gergan Penkov <gergan@gmail.com> Co-authored-by: Dave Marquard <dave@marquard.org> Co-authored-by: Moritz Fago <4459068+MoritzFago@users.noreply.github.com>pull/1312/head
parent
2935aaef45
commit
4b6e46d9ab
29 changed files with 1923 additions and 36 deletions
@ -0,0 +1,10 @@ |
||||
// Copyright 2018 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 googlereader implements Google Reader API endpoints. |
||||
|
||||
*/ |
||||
package googlereader // import "miniflux.app/googlereader"
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,208 @@ |
||||
// Copyright 2018 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 googlereader // import "miniflux.app/googlereader"
|
||||
|
||||
import ( |
||||
"context" |
||||
"crypto/hmac" |
||||
"crypto/sha1" |
||||
"encoding/hex" |
||||
"net/http" |
||||
"strings" |
||||
|
||||
"miniflux.app/http/request" |
||||
"miniflux.app/http/response" |
||||
"miniflux.app/http/response/json" |
||||
"miniflux.app/logger" |
||||
"miniflux.app/model" |
||||
"miniflux.app/storage" |
||||
) |
||||
|
||||
type middleware struct { |
||||
store *storage.Storage |
||||
} |
||||
|
||||
func newMiddleware(s *storage.Storage) *middleware { |
||||
return &middleware{s} |
||||
} |
||||
|
||||
func (m *middleware) clientLogin(w http.ResponseWriter, r *http.Request) { |
||||
clientIP := request.ClientIP(r) |
||||
var username, password, output string |
||||
var integration *model.Integration |
||||
err := r.ParseForm() |
||||
if err != nil { |
||||
logger.Error("[Reader][Login] [ClientIP=%s] Could not parse form", clientIP) |
||||
json.Unauthorized(w, r) |
||||
return |
||||
} |
||||
username = r.Form.Get("Email") |
||||
password = r.Form.Get("Passwd") |
||||
output = r.Form.Get("output") |
||||
|
||||
if username == "" || password == "" { |
||||
logger.Error("[Reader][Login] [ClientIP=%s] Empty username or password", clientIP) |
||||
json.Unauthorized(w, r) |
||||
return |
||||
} |
||||
|
||||
if err = m.store.GoogleReaderUserCheckPassword(username, password); err != nil { |
||||
logger.Error("[Reader][Login] [ClientIP=%s] Invalid username or password: %s", clientIP, username) |
||||
json.Unauthorized(w, r) |
||||
return |
||||
} |
||||
|
||||
logger.Info("[Reader][Login] [ClientIP=%s] User authenticated: %s", clientIP, username) |
||||
|
||||
if integration, err = m.store.GoogleReaderUserGetIntegration(username); err != nil { |
||||
logger.Error("[Reader][Login] [ClientIP=%s] Could not load integration: %s", clientIP, username) |
||||
json.Unauthorized(w, r) |
||||
return |
||||
} |
||||
|
||||
m.store.SetLastLogin(integration.UserID) |
||||
|
||||
token := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword) |
||||
logger.Info("[Reader][Login] [ClientIP=%s] Created token: %s", clientIP, token) |
||||
result := login{SID: token, LSID: token, Auth: token} |
||||
if output == "json" { |
||||
json.OK(w, r, result) |
||||
return |
||||
} |
||||
builder := response.New(w, r) |
||||
builder.WithHeader("Content-Type", "text/plain; charset=UTF-8") |
||||
builder.WithBody(result.String()) |
||||
builder.Write() |
||||
} |
||||
|
||||
func (m *middleware) token(w http.ResponseWriter, r *http.Request) { |
||||
clientIP := request.ClientIP(r) |
||||
|
||||
if !request.IsAuthenticated(r) { |
||||
logger.Error("[Reader][Token] [ClientIP=%s] User is not authenticated", clientIP) |
||||
json.Unauthorized(w, r) |
||||
return |
||||
} |
||||
token := request.GoolgeReaderToken(r) |
||||
if token == "" { |
||||
logger.Error("[Reader][Token] [ClientIP=%s] User does not have token: %s", clientIP, request.UserID(r)) |
||||
json.Unauthorized(w, r) |
||||
return |
||||
} |
||||
logger.Info("[Reader][Token] [ClientIP=%s] token: %s", clientIP, token) |
||||
w.Header().Add("Content-Type", "text/plain; charset=UTF-8") |
||||
w.WriteHeader(http.StatusOK) |
||||
w.Write([]byte(token)) |
||||
} |
||||
|
||||
func (m *middleware) handleCORS(next http.Handler) http.Handler { |
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
w.Header().Set("Access-Control-Allow-Origin", "*") |
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") |
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization") |
||||
if r.Method == http.MethodOptions { |
||||
w.WriteHeader(http.StatusOK) |
||||
return |
||||
} |
||||
next.ServeHTTP(w, r) |
||||
}) |
||||
} |
||||
|
||||
func (m *middleware) apiKeyAuth(next http.Handler) http.Handler { |
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
clientIP := request.ClientIP(r) |
||||
|
||||
var token string |
||||
if r.Method == http.MethodPost { |
||||
err := r.ParseForm() |
||||
if err != nil { |
||||
logger.Error("[Reader][Login] [ClientIP=%s] Could not parse form", clientIP) |
||||
Unauthorized(w, r) |
||||
return |
||||
} |
||||
token = r.Form.Get("T") |
||||
if token == "" { |
||||
logger.Error("[Reader][Auth] [ClientIP=%s] Post-Form T field is empty", clientIP) |
||||
Unauthorized(w, r) |
||||
return |
||||
} |
||||
} else { |
||||
authorization := r.Header.Get("Authorization") |
||||
|
||||
if authorization == "" { |
||||
logger.Error("[Reader][Auth] [ClientIP=%s] No token provided", clientIP) |
||||
Unauthorized(w, r) |
||||
return |
||||
} |
||||
fields := strings.Fields(authorization) |
||||
if len(fields) != 2 { |
||||
logger.Error("[Reader][Auth] [ClientIP=%s] Authorization header does not have the expected structure GoogleLogin auth=xxxxxx - '%s'", clientIP, authorization) |
||||
Unauthorized(w, r) |
||||
return |
||||
} |
||||
if fields[0] != "GoogleLogin" { |
||||
logger.Error("[Reader][Auth] [ClientIP=%s] Authorization header does not begin with GoogleLogin - '%s'", clientIP, authorization) |
||||
Unauthorized(w, r) |
||||
return |
||||
} |
||||
auths := strings.Split(fields[1], "=") |
||||
if len(auths) != 2 { |
||||
logger.Error("[Reader][Auth] [ClientIP=%s] Authorization header does not have the expected structure GoogleLogin auth=xxxxxx - '%s'", clientIP, authorization) |
||||
Unauthorized(w, r) |
||||
return |
||||
} |
||||
if auths[0] != "auth" { |
||||
logger.Error("[Reader][Auth] [ClientIP=%s] Authorization header does not have the expected structure GoogleLogin auth=xxxxxx - '%s'", clientIP, authorization) |
||||
Unauthorized(w, r) |
||||
return |
||||
} |
||||
token = auths[1] |
||||
} |
||||
|
||||
parts := strings.Split(token, "/") |
||||
if len(parts) != 2 { |
||||
logger.Error("[Reader][Auth] [ClientIP=%s] Auth token does not have the expected structure username/hash - '%s'", clientIP, token) |
||||
Unauthorized(w, r) |
||||
return |
||||
} |
||||
var integration *model.Integration |
||||
var user *model.User |
||||
var err error |
||||
if integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil { |
||||
logger.Error("[Reader][Auth] [ClientIP=%s] token: %s", clientIP, token) |
||||
logger.Error("[Reader][Auth] [ClientIP=%s] No user found with the given google reader username: %s", clientIP, parts[0]) |
||||
Unauthorized(w, r) |
||||
return |
||||
} |
||||
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword) |
||||
if expectedToken != token { |
||||
logger.Error("[Reader][Auth] [ClientIP=%s] Token does not match: %s", clientIP, token) |
||||
Unauthorized(w, r) |
||||
return |
||||
} |
||||
if user, err = m.store.UserByID(integration.UserID); err != nil { |
||||
logger.Error("[Reader][Auth] [ClientIP=%s] No user found with the userID: %d", clientIP, integration.UserID) |
||||
Unauthorized(w, r) |
||||
return |
||||
} |
||||
|
||||
m.store.SetLastLogin(integration.UserID) |
||||
|
||||
ctx := r.Context() |
||||
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID) |
||||
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone) |
||||
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin) |
||||
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true) |
||||
ctx = context.WithValue(ctx, request.GoogleReaderToken, token) |
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx)) |
||||
}) |
||||
} |
||||
|
||||
func getAuthToken(username, password string) string { |
||||
token := hex.EncodeToString(hmac.New(sha1.New, []byte(username+password)).Sum(nil)) |
||||
token = username + "/" + token |
||||
return token |
||||
} |
@ -0,0 +1,144 @@ |
||||
// Copyright 2018 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 googlereader // import "miniflux.app/googlereader"
|
||||
|
||||
import ( |
||||
"fmt" |
||||
"net/http" |
||||
|
||||
"miniflux.app/http/response" |
||||
"miniflux.app/logger" |
||||
) |
||||
|
||||
type login struct { |
||||
SID string `json:"SID,omitempty"` |
||||
LSID string `json:"LSID,omitempty"` |
||||
Auth string `json:"Auth,omitempty"` |
||||
} |
||||
|
||||
func (l login) String() string { |
||||
return fmt.Sprintf("SID=%s\nLSID=%s\nAuth=%s\n", l.SID, l.LSID, l.Auth) |
||||
} |
||||
|
||||
type userInfo struct { |
||||
UserID string `json:"userId"` |
||||
UserName string `json:"userName"` |
||||
UserProfileID string `json:"userProfileId"` |
||||
UserEmail string `json:"userEmail"` |
||||
} |
||||
|
||||
type subscription struct { |
||||
ID string `json:"id"` |
||||
Title string `json:"title"` |
||||
Categories []subscriptionCategory `json:"categories"` |
||||
URL string `json:"url"` |
||||
HTMLURL string `json:"htmlUrl"` |
||||
IconURL string `json:"iconUrl"` |
||||
} |
||||
|
||||
type quickAddResponse struct { |
||||
NumResults int64 `json:"numResults"` |
||||
Query string `json:"query,omitempty"` |
||||
StreamID string `json:"streamId,omitempty"` |
||||
StreamName string `json:"streamName,omitempty"` |
||||
} |
||||
|
||||
type subscriptionCategory struct { |
||||
ID string `json:"id"` |
||||
Label string `json:"label,omitempty"` |
||||
Type string `json:"type,omitempty"` |
||||
} |
||||
type subscriptionsResponse struct { |
||||
Subscriptions []subscription `json:"subscriptions"` |
||||
} |
||||
|
||||
type itemRef struct { |
||||
ID string `json:"id"` |
||||
DirectStreamIDs string `json:"directStreamIds,omitempty"` |
||||
TimestampUsec string `json:"timestampUsec,omitempty"` |
||||
} |
||||
|
||||
type streamIDResponse struct { |
||||
ItemRefs []itemRef `json:"itemRefs"` |
||||
} |
||||
|
||||
type tagsResponse struct { |
||||
Tags []subscriptionCategory `json:"tags"` |
||||
} |
||||
|
||||
type streamContentItems struct { |
||||
Direction string `json:"direction"` |
||||
ID string `json:"id"` |
||||
Title string `json:"title"` |
||||
Self []contentHREF `json:"self"` |
||||
Alternate []contentHREFType `json:"alternate"` |
||||
Updated int64 `json:"updated"` |
||||
Items []contentItem `json:"items"` |
||||
Author string `json:"author"` |
||||
} |
||||
|
||||
type contentItem struct { |
||||
ID string `json:"id"` |
||||
Categories []string `json:"categories"` |
||||
Title string `json:"title"` |
||||
CrawlTimeMsec string `json:"crawlTimeMsec"` |
||||
TimestampUsec string `json:"timestampUsec"` |
||||
Published int64 `json:"published"` |
||||
Updated int64 `json:"updated"` |
||||
Author string `json:"author"` |
||||
Alternate []contentHREFType `json:"alternate"` |
||||
Summary contentItemContent `json:"summary"` |
||||
Content contentItemContent `json:"content"` |
||||
Origin contentItemOrigin `json:"origin"` |
||||
Enclosure []contentItemEnclosure `json:"enclosure"` |
||||
Canonical []contentHREF `json:"canonical"` |
||||
} |
||||
|
||||
type contentHREFType struct { |
||||
HREF string `json:"href"` |
||||
Type string `json:"type"` |
||||
} |
||||
|
||||
type contentHREF struct { |
||||
HREF string `json:"href"` |
||||
} |
||||
|
||||
type contentItemEnclosure struct { |
||||
URL string `json:"url"` |
||||
Type string `json:"type"` |
||||
} |
||||
type contentItemContent struct { |
||||
Direction string `json:"direction"` |
||||
Content string `json:"content"` |
||||
} |
||||
|
||||
type contentItemOrigin struct { |
||||
StreamID string `json:"streamId"` |
||||
Title string `json:"title"` |
||||
HTMLUrl string `json:"htmlUrl"` |
||||
} |
||||
|
||||
// Unauthorized sends a not authorized error to the client.
|
||||
func Unauthorized(w http.ResponseWriter, r *http.Request) { |
||||
logger.Error("[HTTP:Unauthorized] %s", r.URL) |
||||
|
||||
builder := response.New(w, r) |
||||
builder.WithStatus(http.StatusUnauthorized) |
||||
builder.WithHeader("Content-Type", "text/plain") |
||||
builder.WithHeader("X-Reader-Google-Bad-Token", "true") |
||||
builder.WithBody("Unauthorized") |
||||
builder.Write() |
||||
} |
||||
|
||||
// OK sends a ok response to the client.
|
||||
func OK(w http.ResponseWriter, r *http.Request) { |
||||
logger.Info("[HTTP:OK] %s", r.URL) |
||||
|
||||
builder := response.New(w, r) |
||||
builder.WithStatus(http.StatusOK) |
||||
builder.WithHeader("Content-Type", "text/plain") |
||||
builder.WithBody("OK") |
||||
builder.Write() |
||||
} |
Loading…
Reference in new issue