Use cookies for authentication instead of header

This replaces the current implementation
where a bearer header holds the auth-token.
Now the same token is being sent using a cookie instead.
This greatly simplifies fetching resources (images and video),
since the header is sent along implicitly with each request.
This commit is contained in:
viktorstrate
2020-07-12 18:52:48 +02:00
parent d681d1538c
commit f669812efb
22 changed files with 246 additions and 217 deletions

View File

@@ -27,31 +27,24 @@ func Middleware(db *sql.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bearer := r.Header.Get("Authorization")
if bearer == "" {
next.ServeHTTP(w, r)
return
if tokenCookie, err := r.Cookie("auth-token"); err == nil {
log.Println("Found auth-token cookie")
user, err := models.VerifyTokenAndGetUser(db, tokenCookie.Value)
if err != nil {
log.Printf("Invalid token: %s\n", err)
http.Error(w, "invalid authorization token", http.StatusForbidden)
return
}
// put it in context
ctx := context.WithValue(r.Context(), userCtxKey, user)
// and call the next with our new context
r = r.WithContext(ctx)
} else {
log.Println("Did not find auth-token cookie")
}
token, err := TokenFromBearer(&bearer)
if err != nil {
log.Printf("Invalid bearer format: %s\n", bearer)
http.Error(w, "Invalid authorization header format", http.StatusBadRequest)
return
}
user, err := models.VerifyTokenAndGetUser(db, *token)
if err != nil {
log.Printf("Invalid token: %s\n", err)
http.Error(w, "Invalid authorization token", http.StatusForbidden)
return
}
// put it in context
ctx := context.WithValue(r.Context(), userCtxKey, user)
// and call the next with our new context
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,79 @@
package routes
import (
"database/sql"
"net/http"
"github.com/viktorstrate/photoview/api/graphql/auth"
"github.com/viktorstrate/photoview/api/graphql/models"
"golang.org/x/crypto/bcrypt"
)
func authenticateMedia(media *models.Media, db *sql.DB, r *http.Request) (success bool, responseMessage string, responseStatus int, errorMessage error) {
user := auth.UserFromContext(r.Context())
if user != nil {
row := db.QueryRow("SELECT owner_id FROM album WHERE album.album_id = ?", media.AlbumId)
var owner_id int
if err := row.Scan(&owner_id); err != nil {
return false, "internal server error", http.StatusInternalServerError, err
}
if owner_id != user.UserID {
return false, "invalid credentials", http.StatusForbidden, nil
}
} else {
// Check if photo is authorized with a share token
token := r.URL.Query().Get("token")
if token == "" {
return false, "unauthorized", http.StatusForbidden, nil
}
row := db.QueryRow("SELECT * FROM share_token WHERE value = ?", token)
shareToken, err := models.NewShareTokenFromRow(row)
if err != nil {
return false, "internal server error", http.StatusInternalServerError, err
}
// Validate share token password, if set
if shareToken.Password != nil {
tokenPassword := r.Header.Get("TokenPassword")
if err := bcrypt.CompareHashAndPassword([]byte(*shareToken.Password), []byte(tokenPassword)); err != nil {
if err == bcrypt.ErrMismatchedHashAndPassword {
return false, "unauthorized", http.StatusForbidden, nil
} else {
return false, "internal server error", http.StatusInternalServerError, err
}
}
}
if shareToken.AlbumID != nil && media.AlbumId != *shareToken.AlbumID {
// Check child albums
row := db.QueryRow(`
WITH recursive child_albums AS (
SELECT * FROM album WHERE parent_album = ?
UNION ALL
SELECT child.* FROM album child JOIN child_albums parent ON parent.album_id = child.parent_album
)
SELECT * FROM child_albums WHERE album_id = ?
`, *shareToken.AlbumID, media.AlbumId)
_, err := models.NewAlbumFromRow(row)
if err != nil {
if err == sql.ErrNoRows {
return false, "unauthorized", http.StatusForbidden, nil
}
return false, "internal server error", http.StatusInternalServerError, err
}
}
if shareToken.MediaID != nil && media.MediaID != *shareToken.MediaID {
return false, "unauthorized", http.StatusForbidden, nil
}
}
return true, "success", http.StatusAccepted, nil
}

View File

@@ -11,9 +11,7 @@ import (
"strconv"
"github.com/gorilla/mux"
"golang.org/x/crypto/bcrypt"
"github.com/viktorstrate/photoview/api/graphql/auth"
"github.com/viktorstrate/photoview/api/graphql/models"
"github.com/viktorstrate/photoview/api/scanner"
)
@@ -43,90 +41,13 @@ func RegisterPhotoRoutes(db *sql.DB, router *mux.Router) {
w.Write([]byte("internal server error"))
}
user := auth.UserFromContext(r.Context())
if user != nil {
row := db.QueryRow("SELECT owner_id FROM album WHERE album.album_id = ?", media.AlbumId)
var owner_id int
if err := row.Scan(&owner_id); err != nil {
log.Printf("WARN: %s", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
return
}
if owner_id != user.UserID {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("invalid credentials"))
return
}
} else {
// Check if photo is authorized with a share token
token := r.URL.Query().Get("token")
if token == "" {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("unauthorized"))
return
}
row := db.QueryRow("SELECT * FROM share_token WHERE value = ?", token)
shareToken, err := models.NewShareTokenFromRow(row)
if success, response, status, err := authenticateMedia(media, db, r); !success {
if err != nil {
log.Printf("WARN: %s", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
return
log.Printf("WARN: error authenticating photo: %s\n", err)
}
// Validate share token password, if set
if shareToken.Password != nil {
tokenPassword := r.Header.Get("TokenPassword")
if err := bcrypt.CompareHashAndPassword([]byte(*shareToken.Password), []byte(tokenPassword)); err != nil {
if err == bcrypt.ErrMismatchedHashAndPassword {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("unauthorized"))
return
} else {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
return
}
}
}
if shareToken.AlbumID != nil && media.AlbumId != *shareToken.AlbumID {
// Check child albums
row := db.QueryRow(`
WITH recursive child_albums AS (
SELECT * FROM album WHERE parent_album = ?
UNION ALL
SELECT child.* FROM album child JOIN child_albums parent ON parent.album_id = child.parent_album
)
SELECT * FROM child_albums WHERE album_id = ?
`, *shareToken.AlbumID, media.AlbumId)
_, err := models.NewAlbumFromRow(row)
if err != nil {
if err == sql.ErrNoRows {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("unauthorized"))
return
}
log.Printf("WARN: %s", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
return
}
}
if shareToken.MediaID != nil && media_id != *shareToken.MediaID {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("unauthorized"))
return
}
w.WriteHeader(status)
w.Write([]byte(response))
return
}
var cachedPath string

View File

@@ -38,6 +38,16 @@ func RegisterVideoRoutes(db *sql.DB, router *mux.Router) {
}
// TODO: Make sure user is authorized to access video
if success, response, status, err := authenticateMedia(media, db, r); !success {
if err != nil {
log.Printf("WARN: error authenticating video: %s\n", err)
}
w.WriteHeader(status)
w.Write([]byte(response))
return
}
log.Printf("Video cookies: %d", len(r.Cookies()))
var cachedPath string

View File

@@ -18,6 +18,7 @@ func CORSMiddleware(devMode bool) mux.MiddlewareFunc {
w.Header().Set("Access-Control-Allow-Methods", strings.Join(methods, ","))
w.Header().Set("Access-Control-Allow-Headers", strings.Join(headers, ","))
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Expose-Headers", "content-length")
endpoint := utils.ApiEndpointUrl()

View File

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types'
import { Route, Redirect } from 'react-router-dom'
import gql from 'graphql-tag'
import { Query } from 'react-apollo'
import { authToken } from './authentication'
const adminQuery = gql`
query adminQuery {
@@ -13,13 +14,13 @@ const adminQuery = gql`
`
export const Authorized = ({ children }) => {
const token = localStorage.getItem('token')
const token = authToken()
return token ? children : null
}
const AuthorizedRoute = ({ component: Component, admin = false, ...props }) => {
const token = localStorage.getItem('token')
const token = authToken()
let unauthorizedRedirect = null
if (!token) {

View File

@@ -6,6 +6,7 @@ import { Button, Form, Message, Header } from 'semantic-ui-react'
import { Container } from './LoginPage'
import { checkInitialSetupQuery, login } from './loginUtilFunctions'
import { authToken } from '../../authentication'
const initialSetupMutation = gql`
mutation InitialSetup(
@@ -53,7 +54,7 @@ class InitialSetupPage extends Component {
}
render() {
if (localStorage.getItem('token')) {
if (authToken()) {
return <Redirect to="/" />
}

View File

@@ -11,6 +11,7 @@ import {
Header,
} from 'semantic-ui-react'
import { checkInitialSetupQuery, login } from './loginUtilFunctions'
import { authToken } from '../../authentication'
import logoPath from '../../assets/photoview-logo.svg'
@@ -69,7 +70,7 @@ class LoginPage extends Component {
}
render() {
if (localStorage.getItem('token')) {
if (authToken()) {
return <Redirect to="/" />
}

View File

@@ -1,4 +1,5 @@
import gql from 'graphql-tag'
import { saveTokenCookie } from '../../authentication'
export const checkInitialSetupQuery = gql`
query CheckInitialSetup {
@@ -9,6 +10,6 @@ export const checkInitialSetupQuery = gql`
`
export function login(token) {
localStorage.setItem('token', token)
saveTokenCookie(token)
window.location = '/'
}

View File

@@ -30,7 +30,12 @@ const MediaView = ({ media }) => {
if (media.type == 'video') {
return (
<DisplayVideo controls key={media.id}>
<DisplayVideo
controls
key={media.id}
crossorigin="use-credentials"
poster={media.thumbnail.url}
>
<source src={media.videoWeb.url} type="video/mp4" />
</DisplayVideo>
)

View File

@@ -3,6 +3,7 @@ import { Route, Switch, Redirect } from 'react-router-dom'
import { Loader } from 'semantic-ui-react'
import Layout from './Layout'
import { clearTokenCookie } from './authentication'
const AlbumsPage = React.lazy(() => import('./Pages/AllAlbumsPage/AlbumsPage'))
const AlbumPage = React.lazy(() => import('./Pages/AlbumPage/AlbumPage'))
@@ -33,7 +34,7 @@ class Routes extends React.Component {
<Route path="/login" component={LoginPage} />
<Route path="/logout">
{() => {
localStorage.removeItem('token')
clearTokenCookie()
location.href = '/'
}}
</Route>

View File

@@ -8,14 +8,15 @@ import { ApolloLink, split } from 'apollo-link'
import { getMainDefinition } from 'apollo-utilities'
import { MessageState } from './components/messages/Messages'
import urlJoin from 'url-join'
import { clearTokenCookie } from './authentication'
const GRAPHQL_ENDPOINT = process.env.API_ENDPOINT
export const GRAPHQL_ENDPOINT = process.env.API_ENDPOINT
? urlJoin(process.env.API_ENDPOINT, '/graphql')
: urlJoin(location.origin, '/api/graphql')
const httpLink = new HttpLink({
uri: GRAPHQL_ENDPOINT,
credentials: 'same-origin',
credentials: 'include',
})
console.log('GRAPHQL ENDPOINT', GRAPHQL_ENDPOINT)
@@ -27,13 +28,7 @@ websocketUri.protocol = apiProtocol === 'https:' ? 'wss:' : 'ws:'
const wsLink = new WebSocketLink({
uri: websocketUri,
credentials: 'same-origin',
options: {
reconnect: true,
connectionParams: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
},
credentials: 'include',
})
const link = split(
@@ -76,7 +71,7 @@ const linkError = onError(({ graphQLErrors, networkError }) => {
if (networkError) {
console.log(`[Network error]: ${JSON.stringify(networkError)}`)
localStorage.removeItem('token')
clearTokenCookie()
const errors = networkError.result.errors
@@ -106,20 +101,9 @@ const linkError = onError(({ graphQLErrors, networkError }) => {
}
})
const authLink = setContext((_, { headers }) => {
// get the authentication token from local storage if it exists
const token = localStorage.getItem('token')
// return the headers to the context so httpLink can read them
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
}
})
const client = new ApolloClient({
link: ApolloLink.from([linkError, authLink.concat(link)]),
// link: ApolloLink.from([linkError, authLink.concat(link)]),
link: ApolloLink.from([linkError, link]),
cache: new InMemoryCache(),
})

14
ui/src/authentication.js Normal file
View File

@@ -0,0 +1,14 @@
export function saveTokenCookie(token) {
const maxAge = 14 * 24 * 60 * 60
document.cookie = `auth-token=${token} ;max-age=${maxAge} ;path=/ ;sameSite=Lax`
}
export function clearTokenCookie() {
document.cookie = 'auth-token= ;max-age=0'
}
export function authToken() {
const match = document.cookie.match(/auth-token=([\d\w]+)/)
return match && match[1]
}

View File

@@ -8,6 +8,7 @@ import { SidebarContext } from './sidebar/Sidebar'
import AlbumSidebar from './sidebar/AlbumSidebar'
import gql from 'graphql-tag'
import { useLazyQuery } from '@apollo/react-hooks'
import { authToken } from '../authentication'
const Header = styled.h1`
margin: 24px 0 8px 0 !important;
@@ -55,7 +56,7 @@ const AlbumTitle = ({ album, disableLink = false }) => {
useEffect(() => {
if (!album) return
if (localStorage.getItem('token') && disableLink == true) {
if (authToken() && disableLink == true) {
fetchPath({
variables: {
id: album.id,
@@ -93,7 +94,7 @@ const AlbumTitle = ({ album, disableLink = false }) => {
<Header>
<Breadcrumb>{breadcrumbSections}</Breadcrumb>
{title}
{localStorage.getItem('token') && (
{authToken() && (
<SettingsIcon
onClick={() => {
updateSidebar(<AlbumSidebar albumId={album.id} />)

View File

@@ -3,6 +3,7 @@ import styled from 'styled-components'
import SearchBar from './Searchbar'
import logoPath from '../../assets/photoview-logo.svg'
import { authToken } from '../../authentication'
const Container = styled.div`
height: 60px;
@@ -40,7 +41,7 @@ const Header = () => (
<Logo src={logoPath} alt="logo" />
<LogoText>Photoview</LogoText>
</Title>
{localStorage.getItem('token') ? <SearchBar /> : null}
{authToken() ? <SearchBar /> : null}
</Container>
)

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useTransition, animated } from 'react-spring'
import styled from 'styled-components'
import { Message } from 'semantic-ui-react'
import { authToken } from '../../authentication'
import MessageProgress from './MessageProgress'
import SubscriptionsHook from './SubscriptionsHook'
@@ -124,7 +125,7 @@ const Messages = () => {
</animated.div>
)
})}
{localStorage.getItem('token') && (
{authToken() && (
<SubscriptionsHook messages={messages} setMessages={setMessages} />
)}
</Container>

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { useSubscription } from 'react-apollo'
import gql from 'graphql-tag'
import { authToken } from '../../authentication'
const notificationSubscription = gql`
subscription notificationSubscription {
@@ -21,7 +22,7 @@ const notificationSubscription = gql`
let messageTimeoutHandles = new Map()
const SubscriptionsHook = ({ messages, setMessages }) => {
if (!localStorage.getItem('token')) {
if (!authToken()) {
return null
}

View File

@@ -1,91 +1,91 @@
import React, { useEffect, useState } from 'react'
import PropTypes from 'prop-types'
let imageCache = {}
// let imageCache = {}
export async function fetchProtectedImage(
src,
{ signal, headers: customHeaders } = { signal: null, headers: null }
) {
if (src) {
if (imageCache[src]) {
return imageCache[src]
}
// export async function fetchProtectedImage(
// src,
// { signal, headers: customHeaders } = { signal: null, headers: null }
// ) {
// if (src) {
// if (imageCache[src]) {
// return imageCache[src]
// }
let headers = {
...customHeaders,
}
if (localStorage.getItem('token')) {
headers['Authorization'] = `Bearer ${localStorage.getItem('token')}`
}
// let headers = {
// ...customHeaders,
// }
// if (localStorage.getItem('token')) {
// headers['Authorization'] = `Bearer ${localStorage.getItem('token')}`
// }
let image = await fetch(src, {
headers,
signal,
})
// let image = await fetch(src, {
// headers,
// signal,
// })
image = await image.blob()
const url = URL.createObjectURL(image)
// image = await image.blob()
// const url = URL.createObjectURL(image)
// eslint-disable-next-line require-atomic-updates
imageCache[src] = url
// // eslint-disable-next-line require-atomic-updates
// imageCache[src] = url
return url
}
}
// return url
// }
// }
/**
* An image that needs a authorization header to load
*/
const ProtectedImage = ({ src, ...props }) => {
const [imgSrc, setImgSrc] = useState(null)
// const [imgSrc, setImgSrc] = useState(null)
useEffect(() => {
if (imageCache[src]) return
// useEffect(() => {
// if (imageCache[src]) return
const fetchController = new AbortController()
let canceled = false
// const fetchController = new AbortController()
// let canceled = false
setImgSrc('')
// setImgSrc('')
const imgUrl = new URL(src)
const fetchHeaders = {}
// const imgUrl = new URL(src)
// const fetchHeaders = {}
if (localStorage.getItem('token') == null) {
// Get share token if not authorized
// if (localStorage.getItem('token') == null) {
// // Get share token if not authorized
const tokenRegex = location.pathname.match(/^\/share\/([\d\w]+)(\/?.*)$/)
if (tokenRegex) {
const token = tokenRegex[1]
imgUrl.searchParams.set('token', token)
// const tokenRegex = location.pathname.match(/^\/share\/([\d\w]+)(\/?.*)$/)
// if (tokenRegex) {
// const token = tokenRegex[1]
// imgUrl.searchParams.set('token', token)
const tokenPassword = sessionStorage.getItem(`share-token-pw-${token}`)
if (tokenPassword) {
fetchHeaders['TokenPassword'] = tokenPassword
}
}
}
// const tokenPassword = sessionStorage.getItem(`share-token-pw-${token}`)
// if (tokenPassword) {
// fetchHeaders['TokenPassword'] = tokenPassword
// }
// }
// }
fetchProtectedImage(imgUrl.href, {
signal: fetchController.signal,
headers: fetchHeaders,
})
.then(newSrc => {
if (!canceled) {
setImgSrc(newSrc)
}
})
.catch(error => {
console.log('Fetch image error', error.message)
})
// fetchProtectedImage(imgUrl.href, {
// signal: fetchController.signal,
// headers: fetchHeaders,
// })
// .then(newSrc => {
// if (!canceled) {
// setImgSrc(newSrc)
// }
// })
// .catch(error => {
// console.log('Fetch image error', error.message)
// })
return function cleanup() {
canceled = true
fetchController.abort()
}
}, [src])
// return function cleanup() {
// canceled = true
// fetchController.abort()
// }
// }, [src])
return <img {...props} src={imageCache[src] || imgSrc} />
return <img {...props} src={src} crossOrigin="use-credentials" />
}
ProtectedImage.propTypes = {

View File

@@ -41,7 +41,12 @@ const PresentMedia = ({ media, imageLoaded, ...otherProps }) => {
if (media.type == 'video') {
return (
<div {...otherProps}>
<StyledVideo controls key={media.id}>
<StyledVideo
controls
key={media.id}
crossorigin="use-credentials"
poster={media.thumbnail.url}
>
<source src={media.videoWeb.url} type="video/mp4" />
</StyledVideo>
</div>

View File

@@ -7,6 +7,7 @@ import SidebarItem from './SidebarItem'
import ProtectedImage from '../photoGallery/ProtectedImage'
import SidebarShare from './Sharing'
import SidebarDownload from './SidebarDownload'
import { authToken } from '../../authentication'
const mediaQuery = gql`
query sidebarPhoto($id: Int!) {
@@ -87,7 +88,12 @@ const PreviewMedia = ({ media, previewImage }) => {
if (media.type == 'video') {
return (
<PreviewVideo controls key={media.id}>
<PreviewVideo
controls
key={media.id}
crossorigin="use-credentials"
poster={media.thumbnail.url}
>
<source src={media.videoWeb.url} type="video/mp4" />
</PreviewVideo>
)
@@ -225,7 +231,7 @@ const MediaSidebar = ({ media, hidePreview }) => {
const [loadMedia, { loading, error, data }] = useLazyQuery(mediaQuery)
useEffect(() => {
if (media != null && localStorage.getItem('token')) {
if (media != null && authToken()) {
loadMedia({
variables: {
id: media.id,
@@ -236,7 +242,7 @@ const MediaSidebar = ({ media, hidePreview }) => {
if (!media) return null
if (!localStorage.getItem('token')) {
if (!authToken()) {
return <SidebarContent media={media} hidePreview={hidePreview} />
}

View File

@@ -11,6 +11,7 @@ import {
Icon,
} from 'semantic-ui-react'
import copy from 'copy-to-clipboard'
import { authToken } from '../../authentication'
const sharePhotoQuery = gql`
query sidbarGetPhotoShares($id: Int!) {
@@ -218,7 +219,7 @@ ShareItemMoreDropdown.propTypes = {
const SidebarShare = ({ photo, album }) => {
if ((!photo || !photo.id) && (!album || !album.id)) return null
if (!localStorage.getItem('token')) return null
if (!authToken()) return null
const isPhoto = !!photo
const id = isPhoto ? photo.id : album.id

View File

@@ -6,6 +6,7 @@ import { MessageState } from '../messages/Messages'
import { useLazyQuery } from 'react-apollo'
import gql from 'graphql-tag'
import download from 'downloadjs'
import { authToken } from '../../authentication'
const downloadQuery = gql`
query sidebarDownloadQuery($mediaId: Int!) {
@@ -30,22 +31,22 @@ function formatBytes(bytes) {
const downloadPhoto = async url => {
const imgUrl = new URL(url)
let headers = {
Authorization: `Bearer ${localStorage.getItem('token')}`,
}
// let headers = {
// Authorization: `Bearer ${localStorage.getItem('token')}`,
// }
if (localStorage.getItem('token') == null) {
if (authToken() == null) {
// Get share token if not authorized
const token = location.pathname.match(/^\/share\/([\d\w]+)(\/?.*)$/)
if (token) {
imgUrl.searchParams.set('token', token[1])
}
headers = {}
// headers = {}
}
const response = await fetch(imgUrl.href, {
headers,
// headers,
})
const totalBytes = Number(response.headers.get('content-length'))