Merge branch 'oauth2'

This commit is contained in:
Meutel 2017-09-09 18:19:45 +02:00
commit 95aab372a5
13 changed files with 540 additions and 49 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ calibre.db
bouquins.json bouquins.json
Gopkg.lock Gopkg.lock
vendor/ vendor/
users.db

View File

@ -6,12 +6,14 @@ Bouquins in Go
* translations * translations
* tests * tests
* auth downloads
* csrf * csrf
* userdb commands (init, migrate, add/remove user/email)
* error pages
## Minify JS ## Minify
https://www.danstools.com/javascript-minify/ * JS: https://www.danstools.com/javascript-minify/
* CSS: curl -X POST -s --data-urlencode 'input@assets/css/bouquins.css' https://cssminifier.com/raw > assets/css/bouquins.min.css
## Deployment archive ## Deployment archive
@ -26,12 +28,39 @@ Example:
{ {
"calibre-path": "/usr/home/meutel/data/calibre", "calibre-path": "/usr/home/meutel/data/calibre",
"bind-address": ":8080", "bind-address": ":8080",
"prod": true "prod": true,
"cookie-secret": "random",
"external-url":"https://bouquins.meutel.net",
"providers": [
{
"name": "github",
"client-id": "ID client",
"client-secret": "SECRET"
},
{
"name": "google",
"client-id":"ID client",
"client-secret":"SECRET"
}
]
} }
Options: Options:
* calibre-path path to calibre data * calibre-path path to calibre data
* db-path path to calibre SQLite database (default <calibre-path>/metadata.db) * db-path path to calibre SQLite database (default <calibre-path>/metadata.db)
* user-db-path path to users SQLite database (default ./users.db)
* bind-address HTTP socket bind address * bind-address HTTP socket bind address
* prod (boolean) use minified javascript/CSS * prod (boolean) use minified javascript/CSS
* cookie-secret random string for cookie encryption
* external-url URL used by client browsers
* providers configuration for OAuth 2 providers
* name provider name
* client-id OAuth client ID
* client-secret OAuth secret
## Users SQL
CREATE TABLE accounts (id varchar(36) PRIMARY KEY NOT NULL, name varchar(255) NOT NULL);
CREATE TABLE authentifiers (id varchar(36) NOT NULL, authentifier varchar(320) PRIMARY KEY NOT NULL, FOREIGN KEY(id) REFERENCES account(id));

14
assets/css/bouquins.css Normal file
View File

@ -0,0 +1,14 @@
span.providericon {
display: inline-block;
vertical-align: middle;
background-size: 16px;
background-repeat: no-repeat;
width: 16px;
height: 16px;
}
.githubicon {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyRpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoTWFjaW50b3NoKSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpFNTE3OEEyQTk5QTAxMUUyOUExNUJDMTA0NkE4OTA0RCIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDpFNTE3OEEyQjk5QTAxMUUyOUExNUJDMTA0NkE4OTA0RCI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOkU1MTc4QTI4OTlBMDExRTI5QTE1QkMxMDQ2QTg5MDREIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOkU1MTc4QTI5OTlBMDExRTI5QTE1QkMxMDQ2QTg5MDREIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+m4QGuQAAAyRJREFUeNrEl21ojWEYx895TDPbMNlBK46IUiNmPvHBSUjaqc0H8pF5+aDUKPEBqU2NhRQpX5Rv5jWlDIWlMCv7MMSWsWwmb3tpXub4XXWdPHvc9/Gc41nu+nedc7/8r/99PffLdYdDPsvkwsgkTBwsA/PADJCnzX2gHTwBt8Hl7p537/3whn04XoDZDcpBlk+9P8AFcAghzRkJwPF4zGGw0Y9QS0mAM2AnQj77FqCzrtcwB1Hk81SYojHK4DyGuQ6mhIIrBWB9Xm7ug/6B/nZrBHBegrkFxoVGpnwBMSLR9EcEcC4qb8pP14BWcBcUgewMnF3T34VqhWMFkThLJAalwnENOAKiHpJq1FZgI2AT6HZtuxZwR9GidSHtI30jOrbawxlVX78/AbNfhHlomEUJJI89O2MqeE79T8/nk8nMBm/dK576hZgmA3cp/R4l9/UeSxiHLVIlNm4nFfT0bxyuIj7LHRTKai+zdJobwMKzcZSJb0ePV5PKN+BqAAKE47UlMnERELMM3EdYP/yrd+XYb2mOiYBiQ8OQnoRBlXrl9JZix7D1pHTazu4MoyBcnYamqAjIMTR8G4FT8LuhLsexXYYjICBiqhQBvYb6fLZIJCjPypVvaOoVAW2WcasCnL2Nq82xHJNSqlCeFcDshaPK0twkAhosjZL31QYw+1rlMpWGMArl23SBsZZO58F2tlJXmjOXS+s4WGvpMiBJT/I2PInZ6lIs9/hBsNS1hS6BG0DSqmYEDRlCXQrmy50P1oDRKTSegmNbUsA0zDMwRhPJXeCE3vWLPQMvan6X8AgIa1vcR4AkGZkDR4ejJ1UHpsaVI0g2LInpOsNFUud1rhxSV+fzC9Woz2EZkWQuja7/B+jUrgtIMpy9YCW4n4K41YfzRneW5E1KJTe4B2Zq1Q5EHEtj4U3AfEzR5SVY4l7QYQPJdN2as7RKBF0BPZqqH4VgMAMBL8Byxr7y8zCZiDlnOcEKIPmUpgB5Z2ww5RdOiiRiNajUmWda5IG6WbhsyY2fx6m8gLcoJDJFkH219M3We1+cnda93pfycZpIJEL/s/wSYADmOAwAQgdpBAAAAABJRU5ErkJggg==);
}
.googleicon {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAB1FBMVEUAAAD/AADsQzXrQzbqQzXpRDTqQzXqQjXqQzXqRDToRjbqQzXqQzXqQzXqRDXrRTHxRznqQzXpQzXqQzXpQzTjOTnrQTTqQzXqQzToRDPpQjfqQzXqQzXpQzbsRDjqQzXoRDfqQzXqQzXrQzXqRDXqQjXqQzbqQzXqQzXqQzTsQjn/rxDqRDTqQzXqRDXoRjr8vAX5sQrsTDHrQjT//wD7vAT7ugbvZif6vgX1jRjqQzX7vAT5sAo/jss9kb77uwRAieH6vQX6vQX6vAX6vQVBiOnkuA4/i9r6vAWstCQ1qVI3pFI9k7E+j8n4vAZvrT00qFM0p1M/jso+lLn8vAXkug1CqU00qFQ8lK45l6ffvxg3qFI0qFM9lqpBieU/jc00qFQzqFM1p09An2A0plk/jdA5lqwtpVo0qFM0qFM1qVQ1qFM1p1I0p1IzqVI1qFM1qFM+j8gzplM0qFNAi91Cl6o1qVM0qFM/jNQ7m602qFE0qFM1qFMxpVI0qVM0qFM0qFM0qFQktkk4p1A0qFM0qVM0qVMzo1IA/wA2p1M0qFIzqFIzp1QzqFI0p1PqQzX7vAVChfRChu9BhvA0qFNChfJBhfM1p1o9krs5mpQ3oHf////8WgVEAAAAj3RSTlMAATVylKafh24xIZXl5o8aEpD5940JJ9nWLUby8Tkp8Dje+rt8YF/7/pwbIOX2VhaV58xZAeP7xTfK63Kv47x8+6VgpGH+sfU1y+wcfd/7xv6d36SU6NJYYjEg5fdL/OePnx0IcPxQEdz7vX5gXXOl7tko8PgbRPH6OCbYzB+O+PaJByCT4+YZATRwpIZtMQ4TRwgAAAABYktHRJvv2FeEAAAAB3RJTUUH4QgKAjghFnOx6QAAAWBJREFUOMtjYCABMDIxs7CysXNwMmKV5uLm6YcCXj5+DGkBQaF+JCAsIooqLybejwYkJJHlpaTR5ftlZJHk5eQx5RWQ7VeEiiopq6iqqSiro+lnEIRIa2hqQf3DiqKfQVsHLK+rhxDSR/GBgaERSL8xrvAzMZ1gZq7Rr4kzgC0mAIGllRZOBdYgBRNsYFxbZGAHdgJYgT1MwURk4AAScQQrcMKqYBJIxBmswAWrgsmErHBFONINqwJ3kIgHSN7TyxvVbz5gBb7QgPLzD5gSiKogCKwgGMwOCQ2bMmVKQDiyfMRUkPy0SDAnKnoKCMQgqYiNAxsQD+UmgBVMCUhMgvCTU1LB8lPToArSMyAqpmRmZefk5uUXTJk+A6SgEG5iUfEUdDBz8sSSUoSdZeUYKmZVVCK7uqoaXUFNLaq/0+vqkaUbGpsw0kVzSytMui2hHWvS6ejsaulO7Ont6yAlywMAh+DsfszQdOIAAAAldEVYdGRhdGU6Y3JlYXRlADIwMTctMDgtMTBUMDI6NTY6MzMrMDA6MDAy1cN5AAAAJXRFWHRkYXRlOm1vZGlmeQAyMDE3LTA4LTEwVDAyOjU2OjMzKzAwOjAwQ4h7xQAAAABJRU5ErkJggg==);
}

1
assets/css/bouquins.min.css vendored Normal file
View File

@ -0,0 +1 @@
span.providericon{display:inline-block;vertical-align:middle;background-size:16px;background-repeat:no-repeat;width:16px;height:16px}.githubicon{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyRpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoTWFjaW50b3NoKSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpFNTE3OEEyQTk5QTAxMUUyOUExNUJDMTA0NkE4OTA0RCIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDpFNTE3OEEyQjk5QTAxMUUyOUExNUJDMTA0NkE4OTA0RCI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOkU1MTc4QTI4OTlBMDExRTI5QTE1QkMxMDQ2QTg5MDREIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOkU1MTc4QTI5OTlBMDExRTI5QTE1QkMxMDQ2QTg5MDREIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+m4QGuQAAAyRJREFUeNrEl21ojWEYx895TDPbMNlBK46IUiNmPvHBSUjaqc0H8pF5+aDUKPEBqU2NhRQpX5Rv5jWlDIWlMCv7MMSWsWwmb3tpXub4XXWdPHvc9/Gc41nu+nedc7/8r/99PffLdYdDPsvkwsgkTBwsA/PADJCnzX2gHTwBt8Hl7p537/3whn04XoDZDcpBlk+9P8AFcAghzRkJwPF4zGGw0Y9QS0mAM2AnQj77FqCzrtcwB1Hk81SYojHK4DyGuQ6mhIIrBWB9Xm7ug/6B/nZrBHBegrkFxoVGpnwBMSLR9EcEcC4qb8pP14BWcBcUgewMnF3T34VqhWMFkThLJAalwnENOAKiHpJq1FZgI2AT6HZtuxZwR9GidSHtI30jOrbawxlVX78/AbNfhHlomEUJJI89O2MqeE79T8/nk8nMBm/dK576hZgmA3cp/R4l9/UeSxiHLVIlNm4nFfT0bxyuIj7LHRTKai+zdJobwMKzcZSJb0ePV5PKN+BqAAKE47UlMnERELMM3EdYP/yrd+XYb2mOiYBiQ8OQnoRBlXrl9JZix7D1pHTazu4MoyBcnYamqAjIMTR8G4FT8LuhLsexXYYjICBiqhQBvYb6fLZIJCjPypVvaOoVAW2WcasCnL2Nq82xHJNSqlCeFcDshaPK0twkAhosjZL31QYw+1rlMpWGMArl23SBsZZO58F2tlJXmjOXS+s4WGvpMiBJT/I2PInZ6lIs9/hBsNS1hS6BG0DSqmYEDRlCXQrmy50P1oDRKTSegmNbUsA0zDMwRhPJXeCE3vWLPQMvan6X8AgIa1vcR4AkGZkDR4ejJ1UHpsaVI0g2LInpOsNFUud1rhxSV+fzC9Woz2EZkWQuja7/B+jUrgtIMpy9YCW4n4K41YfzRneW5E1KJTe4B2Zq1Q5EHEtj4U3AfEzR5SVY4l7QYQPJdN2as7RKBF0BPZqqH4VgMAMBL8Byxr7y8zCZiDlnOcEKIPmUpgB5Z2ww5RdOiiRiNajUmWda5IG6WbhsyY2fx6m8gLcoJDJFkH219M3We1+cnda93pfycZpIJEL/s/wSYADmOAwAQgdpBAAAAABJRU5ErkJggg==)}.googleicon{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAB1FBMVEUAAAD/AADsQzXrQzbqQzXpRDTqQzXqQjXqQzXqRDToRjbqQzXqQzXqQzXqRDXrRTHxRznqQzXpQzXqQzXpQzTjOTnrQTTqQzXqQzToRDPpQjfqQzXqQzXpQzbsRDjqQzXoRDfqQzXqQzXrQzXqRDXqQjXqQzbqQzXqQzXqQzTsQjn/rxDqRDTqQzXqRDXoRjr8vAX5sQrsTDHrQjT//wD7vAT7ugbvZif6vgX1jRjqQzX7vAT5sAo/jss9kb77uwRAieH6vQX6vQX6vAX6vQVBiOnkuA4/i9r6vAWstCQ1qVI3pFI9k7E+j8n4vAZvrT00qFM0p1M/jso+lLn8vAXkug1CqU00qFQ8lK45l6ffvxg3qFI0qFM9lqpBieU/jc00qFQzqFM1p09An2A0plk/jdA5lqwtpVo0qFM0qFM1qVQ1qFM1p1I0p1IzqVI1qFM1qFM+j8gzplM0qFNAi91Cl6o1qVM0qFM/jNQ7m602qFE0qFM1qFMxpVI0qVM0qFM0qFM0qFQktkk4p1A0qFM0qVM0qVMzo1IA/wA2p1M0qFIzqFIzp1QzqFI0p1PqQzX7vAVChfRChu9BhvA0qFNChfJBhfM1p1o9krs5mpQ3oHf////8WgVEAAAAj3RSTlMAATVylKafh24xIZXl5o8aEpD5940JJ9nWLUby8Tkp8Dje+rt8YF/7/pwbIOX2VhaV58xZAeP7xTfK63Kv47x8+6VgpGH+sfU1y+wcfd/7xv6d36SU6NJYYjEg5fdL/OePnx0IcPxQEdz7vX5gXXOl7tko8PgbRPH6OCbYzB+O+PaJByCT4+YZATRwpIZtMQ4TRwgAAAABYktHRJvv2FeEAAAAB3RJTUUH4QgKAjghFnOx6QAAAWBJREFUOMtjYCABMDIxs7CysXNwMmKV5uLm6YcCXj5+DGkBQaF+JCAsIooqLybejwYkJJHlpaTR5ftlZJHk5eQx5RWQ7VeEiiopq6iqqSiro+lnEIRIa2hqQf3DiqKfQVsHLK+rhxDSR/GBgaERSL8xrvAzMZ1gZq7Rr4kzgC0mAIGllRZOBdYgBRNsYFxbZGAHdgJYgT1MwURk4AAScQQrcMKqYBJIxBmswAWrgsmErHBFONINqwJ3kIgHSN7TyxvVbz5gBb7QgPLzD5gSiKogCKwgGMwOCQ2bMmVKQDiyfMRUkPy0SDAnKnoKCMQgqYiNAxsQD+UmgBVMCUhMgvCTU1LB8lPToArSMyAqpmRmZefk5uUXTJk+A6SgEG5iUfEUdDBz8sSSUoSdZeUYKmZVVCK7uqoaXUFNLaq/0+vqkaUbGpsw0kVzSytMui2hHWvS6ejsaulO7Ont6yAlywMAh+DsfszQdOIAAAAldEVYdGRhdGU6Y3JlYXRlADIwMTctMDgtMTBUMDI6NTY6MzMrMDA6MDAy1cN5AAAAJXRFWHRkYXRlOm1vZGlmeQAyMDE3LTA4LTEwVDAyOjU2OjMzKzAwOjAwQ4h7xQAAAABJRU5ErkJggg==)}

138
bouquins/auth.go Normal file
View File

@ -0,0 +1,138 @@
package bouquins
import (
"fmt"
"log"
"math/rand"
"net/http"
"github.com/gorilla/sessions"
"golang.org/x/oauth2"
)
const (
alphanums = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
sessionName = "bouquins"
sessionOAuthState = "oauthState"
sessionOAuthProvider = "provider"
sessionUser = "username"
pProvider = "provider"
)
var (
// Providers contains OAuth2 providers implementations
Providers []OAuth2Provider
)
// LoginModel is login page model
type LoginModel struct {
Model
Providers []OAuth2Provider
}
// NewLoginModel constructor for LoginModel
func (app *Bouquins) NewLoginModel(req *http.Request) *LoginModel {
return &LoginModel{*app.NewModel("Authentification", "provider", req), Providers}
}
// OAuth2Provider allows to get a user from an OAuth2 token
type OAuth2Provider interface {
GetUser(token *oauth2.Token) (string, error)
Config(conf *Conf) *oauth2.Config
Name() string
Label() string
Icon() string
}
// generates a 16 characters long random string
func securedRandString() string {
b := make([]byte, 16)
for i := range b {
b[i] = alphanums[rand.Intn(len(alphanums))]
}
return string(b)
}
// Session returns current session
func (app *Bouquins) Session(req *http.Request) *sessions.Session {
session, _ := app.Cookies.Get(req, sessionName)
return session
}
// Username returns logged in username
func (app *Bouquins) Username(req *http.Request) string {
username := app.Session(req).Values[sessionUser]
if username != nil {
return username.(string)
}
return ""
}
// SessionSet sets a value in session
func (app *Bouquins) SessionSet(name string, value string, res http.ResponseWriter, req *http.Request) {
session := app.Session(req)
session.Values[name] = value
session.Save(req, res)
}
// LoginPage redirects to OAuth login page (github)
func (app *Bouquins) LoginPage(res http.ResponseWriter, req *http.Request) error {
provider := req.URL.Query().Get(pProvider)
oauth := app.OAuthConf[provider]
if oauth != nil {
app.SessionSet(sessionOAuthProvider, provider, res, req)
state := securedRandString()
app.SessionSet(sessionOAuthState, state, res, req)
url := oauth.AuthCodeURL(state)
log.Println("OAuth redirect", url)
http.Redirect(res, req, url, http.StatusTemporaryRedirect)
return nil
}
// choose provider
return app.render(res, tplProvider, app.NewLoginModel(req))
}
// LogoutPage logout connected user
func (app *Bouquins) LogoutPage(res http.ResponseWriter, req *http.Request) error {
app.SessionSet(sessionUser, "", res, req)
return RedirectHome(res, req)
}
// CallbackPage handle OAuth 2 callback
func (app *Bouquins) CallbackPage(res http.ResponseWriter, req *http.Request) error {
savedState := app.Session(req).Values[sessionOAuthState]
providerParam := app.Session(req).Values[sessionOAuthProvider]
if savedState == "" || providerParam == "" {
return fmt.Errorf("missing oauth data")
}
providerName := providerParam.(string)
oauth := app.OAuthConf[providerName]
provider := findProvider(providerName)
if oauth == nil || provider == nil {
return fmt.Errorf("missing oauth configuration")
}
app.SessionSet(sessionOAuthState, "", res, req)
app.SessionSet(sessionOAuthProvider, "", res, req)
state := req.FormValue("state")
if state != savedState {
return fmt.Errorf("invalid oauth state, expected '%s', got '%s'", "state", state)
}
code := req.FormValue("code")
token, err := oauth.Exchange(oauth2.NoContext, code)
if err != nil {
return fmt.Errorf("Code exchange failed with '%s'", err)
}
userEmail, err := provider.GetUser(token)
if err != nil {
return err
}
user, err := Account(userEmail)
if err != nil {
log.Println("Error loading user", err)
return fmt.Errorf("Unknown user")
}
app.SessionSet(sessionUser, user.DisplayName, res, req)
log.Println("User logged in", user.DisplayName)
return RedirectHome(res, req)
}

View File

@ -12,10 +12,14 @@ import (
"strconv" "strconv"
"strings" "strings"
"golang.org/x/oauth2"
"github.com/c2h5oh/datasize" "github.com/c2h5oh/datasize"
"github.com/gorilla/sessions"
) )
const ( const (
// Version defines application version
Version = "master" Version = "master"
tplBooks = "book.html" tplBooks = "book.html"
@ -24,6 +28,7 @@ const (
tplIndex = "index.html" tplIndex = "index.html"
tplSearch = "search.html" tplSearch = "search.html"
tplAbout = "about.html" tplAbout = "about.html"
tplProvider = "provider.html"
pList = "list" pList = "list"
pOrder = "order" pOrder = "order"
@ -34,6 +39,12 @@ const (
// URLIndex url of index page // URLIndex url of index page
URLIndex = "/" URLIndex = "/"
// URLLogin url of login page (OAuth 2)
URLLogin = "/login"
// URLLogout url of logout page
URLLogout = "/logout"
// URLCallback url of OAuth callback
URLCallback = "/callback"
// URLBooks url of books page // URLBooks url of books page
URLBooks = "/books/" URLBooks = "/books/"
// URLAuthors url of authors page // URLAuthors url of authors page
@ -54,10 +65,42 @@ const (
URLCalibre = "/calibre/" URLCalibre = "/calibre/"
) )
// UnprotectedCalibreSuffix lists suffixe of calibre file not protected by auth
var UnprotectedCalibreSuffix = [1]string{"jpg"}
// Conf App configuration
type Conf struct {
BindAddress string `json:"bind-address"`
DbPath string `json:"db-path"`
CalibrePath string `json:"calibre-path"`
Prod bool `json:"prod"`
UserDbPath string `json:"user-db-path"`
CookieSecret string `json:"cookie-secret"`
ExternalURL string `json:"external-url"`
ProvidersConf []ProviderConf `json:"providers"`
}
// ProviderConf OAuth2 provider configuration
type ProviderConf struct {
Name string `json:"name"`
ClientID string `json:"client-id"`
ClientSecret string `json:"client-secret"`
}
// Bouquins contains application common resources: templates, database // Bouquins contains application common resources: templates, database
type Bouquins struct { type Bouquins struct {
Tpl *template.Template Tpl *template.Template
DB *sql.DB *sql.DB
UserDB *sql.DB
*Conf
OAuthConf map[string]*oauth2.Config
Cookies *sessions.CookieStore
}
// UserAccount is an user account
type UserAccount struct {
ID string // UUID
DisplayName string
} }
// Series is a book series. // Series is a book series.
@ -141,11 +184,17 @@ type Model struct {
Title string Title string
Page string Page string
Version string Version string
Username string
} }
// NewModel constuctor for Model // NewModel constructor for Model
func NewModel(title, page string) *Model { func (app *Bouquins) NewModel(title, page string, req *http.Request) *Model {
return &Model{title, page, Version} return &Model{
Title: title,
Page: page,
Version: Version,
Username: app.Username(req),
}
} }
// IndexModel is the model for index page // IndexModel is the model for index page
@ -155,13 +204,13 @@ type IndexModel struct {
} }
// NewIndexModel constructor IndexModel // NewIndexModel constructor IndexModel
func NewIndexModel(title string, count int64) *IndexModel { func (app *Bouquins) NewIndexModel(title string, count int64, req *http.Request) *IndexModel {
return &IndexModel{*NewModel(title, "index"), count} return &IndexModel{*app.NewModel(title, "index", req), count}
} }
// NewSearchModel constuctor for search page // NewSearchModel constuctor for search page
func NewSearchModel() *Model { func (app *Bouquins) NewSearchModel(req *http.Request) *Model {
return NewModel("Recherche", "search") return app.NewModel("Recherche", "search", req)
} }
// ResultsModel is a generic model for list pages // ResultsModel is a generic model for list pages
@ -255,6 +304,12 @@ func TemplatesFunc(prod bool) *template.Template {
}) })
} }
// RedirectHome redirects to home page
func RedirectHome(res http.ResponseWriter, req *http.Request) error {
http.Redirect(res, req, "/", http.StatusTemporaryRedirect)
return nil
}
// output page with template // output page with template
func (app *Bouquins) render(res http.ResponseWriter, tpl string, model interface{}) error { func (app *Bouquins) render(res http.ResponseWriter, tpl string, model interface{}) error {
return app.Tpl.ExecuteTemplate(res, tpl, model) return app.Tpl.ExecuteTemplate(res, tpl, model)
@ -370,7 +425,7 @@ func (app *Bouquins) bookPage(idParam string, res http.ResponseWriter, req *http
if err != nil { if err != nil {
return err return err
} }
return app.render(res, tplBooks, &BookModel{*NewModel(book.Title, "book"), book}) return app.render(res, tplBooks, &BookModel{*app.NewModel(book.Title, "book", req), book})
} }
func (app *Bouquins) authorPage(idParam string, res http.ResponseWriter, req *http.Request) error { func (app *Bouquins) authorPage(idParam string, res http.ResponseWriter, req *http.Request) error {
id, err := strconv.Atoi(idParam) id, err := strconv.Atoi(idParam)
@ -381,7 +436,7 @@ func (app *Bouquins) authorPage(idParam string, res http.ResponseWriter, req *ht
if err != nil { if err != nil {
return err return err
} }
return app.render(res, tplAuthors, &AuthorModel{*NewModel(author.Name, "author"), author}) return app.render(res, tplAuthors, &AuthorModel{*app.NewModel(author.Name, "author", req), author})
} }
func (app *Bouquins) seriePage(idParam string, res http.ResponseWriter, req *http.Request) error { func (app *Bouquins) seriePage(idParam string, res http.ResponseWriter, req *http.Request) error {
id, err := strconv.Atoi(idParam) id, err := strconv.Atoi(idParam)
@ -392,7 +447,7 @@ func (app *Bouquins) seriePage(idParam string, res http.ResponseWriter, req *htt
if err != nil { if err != nil {
return err return err
} }
return app.render(res, tplSeries, &SeriesModel{*NewModel(series.Name, "series"), series}) return app.render(res, tplSeries, &SeriesModel{*app.NewModel(series.Name, "series", req), series})
} }
// ROUTES // // ROUTES //
@ -414,12 +469,12 @@ func (app *Bouquins) SeriesPage(res http.ResponseWriter, req *http.Request) erro
// SearchPage displays search form and results // SearchPage displays search form and results
func (app *Bouquins) SearchPage(res http.ResponseWriter, req *http.Request) error { func (app *Bouquins) SearchPage(res http.ResponseWriter, req *http.Request) error {
return app.render(res, tplSearch, NewSearchModel()) return app.render(res, tplSearch, app.NewSearchModel(req))
} }
// AboutPage displays about page // AboutPage displays about page
func (app *Bouquins) AboutPage(res http.ResponseWriter, req *http.Request) error { func (app *Bouquins) AboutPage(res http.ResponseWriter, req *http.Request) error {
return app.render(res, tplAbout, NewModel("A propos", "about")) return app.render(res, tplAbout, app.NewModel("A propos", "about", req))
} }
// IndexPage displays index page: list of books/authors/series // IndexPage displays index page: list of books/authors/series
@ -428,9 +483,27 @@ func (app *Bouquins) IndexPage(res http.ResponseWriter, req *http.Request) error
if err != nil { if err != nil {
return err return err
} }
model := NewIndexModel("", count) model := app.NewIndexModel("", count, req)
if isJSON(req) { if isJSON(req) {
return writeJSON(res, model) return writeJSON(res, model)
} }
return app.render(res, tplIndex, model) return app.render(res, tplIndex, model)
} }
func (app *Bouquins) CalibreFileServer() http.Handler {
calibre := app.Conf.CalibrePath
handler := http.StripPrefix(URLCalibre, http.FileServer(http.Dir(calibre)))
return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
for _, suffix := range UnprotectedCalibreSuffix {
if strings.HasSuffix(req.URL.Path, suffix) {
handler.ServeHTTP(res, req)
}
}
// check auth
if app.Username(req) == "" {
http.Error(res, "401 Unauthorized", http.StatusUnauthorized)
} else {
handler.ServeHTTP(res, req)
}
})
}

View File

@ -104,6 +104,8 @@ const (
AND authors.id != ? ORDER BY authors.id` AND authors.id != ? ORDER BY authors.id`
sqlAuthor = "SELECT name FROM authors WHERE id = ?" sqlAuthor = "SELECT name FROM authors WHERE id = ?"
sqlAccount = "SELECT accounts.id, name FROM accounts, authentifiers WHERE authentifiers.id = accounts.id AND authentifiers.authentifier = ?"
defaultLimit = 10 defaultLimit = 10
qtBook QueryType = iota qtBook QueryType = iota
@ -162,7 +164,10 @@ var queries = map[Query]string{
Query{qtAuthorBooks, false, false}: sqlAuthorBooks, Query{qtAuthorBooks, false, false}: sqlAuthorBooks,
Query{qtAuthorCoauthors, false, false}: sqlAuthorAuthors, Query{qtAuthorCoauthors, false, false}: sqlAuthorAuthors,
} }
var stmts = make(map[Query]*sql.Stmt) var (
stmts = make(map[Query]*sql.Stmt)
stmtAccount *sql.Stmt
)
// QueryType is a type of query, with variants for sort and order // QueryType is a type of query, with variants for sort and order
type QueryType uint type QueryType uint
@ -214,6 +219,13 @@ func (app *Bouquins) PrepareAll() error {
} }
stmts[q] = stmt stmts[q] = stmt
} }
// users.db
var err error
stmtAccount, err = app.UserDB.Prepare(sqlAccount)
if err != nil {
log.Println(err, sqlAccount)
errcount++
}
if errcount > 0 { if errcount > 0 {
return fmt.Errorf("%d errors on queries, see logs", errcount) return fmt.Errorf("%d errors on queries, see logs", errcount)
} }

11
bouquins/dbusers.go Normal file
View File

@ -0,0 +1,11 @@
package bouquins
// Account returns user account from authentifier
func Account(authentifier string) (*UserAccount, error) {
account := new(UserAccount)
err := stmtAccount.QueryRow(authentifier).Scan(&account.ID, &account.DisplayName)
if err != nil {
return nil, err
}
return account, nil
}

94
bouquins/github.go Normal file
View File

@ -0,0 +1,94 @@
package bouquins
import (
"encoding/json"
"fmt"
"log"
"net/http"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
)
// GithubProvider implements OAuth2 client with github.com
type GithubProvider string
type githubEmail struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
Visibility string `json:"visibility"`
}
func init() {
Providers = append(Providers, GithubProvider("github"))
}
// Name returns name of provider
func (p GithubProvider) Name() string {
return string(p)
}
// Label returns label of provider
func (p GithubProvider) Label() string {
return "Github"
}
// Icon returns icon CSS class for provider
func (p GithubProvider) Icon() string {
return "githubicon"
}
// Config returns OAuth configuration for this provider
func (p GithubProvider) Config(conf *Conf) *oauth2.Config {
for _, c := range conf.ProvidersConf {
if c.Name == p.Name() {
return &oauth2.Config{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
Scopes: []string{"user:email"},
Endpoint: github.Endpoint,
}
}
}
return nil
}
// GetUser returns github primary email
func (p GithubProvider) GetUser(token *oauth2.Token) (string, error) {
apiReq, err := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
apiReq.Header.Add("Accept", "application/vnd.github.v3+json")
apiReq.Header.Add("Authorization", "token "+token.AccessToken)
client := &http.Client{}
response, err := client.Do(apiReq)
defer response.Body.Close()
if err != nil {
log.Println("Auth error", err)
return "", fmt.Errorf("Authentification error")
}
dec := json.NewDecoder(response.Body)
var emails []githubEmail
err = dec.Decode(&emails)
if err != nil {
log.Println("Error reading github API response", err)
return "", fmt.Errorf("Error reading github API response")
}
var userEmail string
for _, email := range emails {
if email.Primary && email.Verified {
userEmail = email.Email
}
}
log.Println("User email:", userEmail)
return userEmail, nil
}
func findProvider(name string) OAuth2Provider {
for _, p := range Providers {
if p.Name() == name {
return p
}
}
return nil
}

83
bouquins/google.go Normal file
View File

@ -0,0 +1,83 @@
package bouquins
import (
"encoding/json"
"fmt"
"log"
"net/http"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
// GoogleProvider implements OAuth2 client with google account
type GoogleProvider string
type googleTokenInfo struct {
IssuedTo string `json:"issued_to"`
Audience string `json:"audience"`
UserID string `json:"user_id"`
Scope string `json:"scope"`
ExpiresIn int64 `json:"expires_in"`
Email string `json:"email"`
VerifiedEmail bool `json:"verified_email"`
AccessType string `json:"access_type"`
}
func init() {
Providers = append(Providers, GoogleProvider("google"))
}
// Name returns name of provider
func (p GoogleProvider) Name() string {
return string(p)
}
// Label returns label of provider
func (p GoogleProvider) Label() string {
return "Google"
}
// Icon returns icon path for provider
func (p GoogleProvider) Icon() string {
return "googleicon"
}
// Config returns OAuth configuration for this provider
func (p GoogleProvider) Config(conf *Conf) *oauth2.Config {
for _, c := range conf.ProvidersConf {
if c.Name == p.Name() {
return &oauth2.Config{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"},
Endpoint: google.Endpoint,
RedirectURL: conf.ExternalURL + URLCallback,
}
}
}
return nil
}
// GetUser returns github primary email
func (p GoogleProvider) GetUser(token *oauth2.Token) (string, error) {
apiRes, err := http.Post("https://www.googleapis.com/oauth2/v2/tokeninfo?access_token="+token.AccessToken, "application/json", nil)
defer apiRes.Body.Close()
if err != nil {
log.Println("Auth error", err)
return "", fmt.Errorf("Authentification error")
}
dec := json.NewDecoder(apiRes.Body)
var tokenInfo googleTokenInfo
err = dec.Decode(&tokenInfo)
if err != nil {
log.Println("Error reading google API response", err)
return "", fmt.Errorf("Error reading google API response")
}
var userEmail string
if tokenInfo.VerifiedEmail {
userEmail = tokenInfo.Email
}
log.Println("User email:", userEmail)
return userEmail, nil
}

57
main.go
View File

@ -7,24 +7,17 @@ import (
"net/http" "net/http"
"os" "os"
"golang.org/x/oauth2"
"github.com/gorilla/sessions"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"meutel.net/meutel/go-bouquins/bouquins" "meutel.net/meutel/go-bouquins/bouquins"
) )
// BouquinsConf App configuration
type BouquinsConf struct {
BindAddress string `json:"bind-address"`
DbPath string `json:"db-path"`
CalibrePath string `json:"calibre-path"`
Prod bool `json:"prod"`
}
var db *sql.DB
// ReadConfig loads configuration file and initialize default value // ReadConfig loads configuration file and initialize default value
func ReadConfig() (*BouquinsConf, error) { func ReadConfig() (*bouquins.Conf, error) {
conf := new(BouquinsConf) conf := new(bouquins.Conf)
confPath := "bouquins.json" confPath := "bouquins.json"
if len(os.Args) > 1 { if len(os.Args) > 1 {
confPath = os.Args[1] confPath = os.Args[1]
@ -44,13 +37,16 @@ func ReadConfig() (*BouquinsConf, error) {
if conf.DbPath == "" { if conf.DbPath == "" {
conf.DbPath = conf.CalibrePath + "/metadata.db" conf.DbPath = conf.CalibrePath + "/metadata.db"
} }
if conf.UserDbPath == "" {
conf.UserDbPath = "./users.db"
}
if conf.BindAddress == "" { if conf.BindAddress == "" {
conf.BindAddress = ":9000" conf.BindAddress = ":9000"
} }
return conf, err return conf, err
} }
func initApp() *BouquinsConf { func initApp() *bouquins.Bouquins {
log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetFlags(log.LstdFlags | log.Lshortfile)
conf, err := ReadConfig() conf, err := ReadConfig()
if err != nil { if err != nil {
@ -61,25 +57,38 @@ func initApp() *BouquinsConf {
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
db, err = sql.Open("sqlite3", conf.DbPath) db, err := sql.Open("sqlite3", conf.DbPath)
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
app := &bouquins.Bouquins{Tpl: tpl, DB: db} userdb, err := sql.Open("sqlite3", conf.UserDbPath)
if err != nil {
log.Fatalln(err)
}
app := &bouquins.Bouquins{
Tpl: tpl,
DB: db,
UserDB: userdb,
Conf: conf,
OAuthConf: make(map[string]*oauth2.Config),
Cookies: sessions.NewCookieStore([]byte(conf.CookieSecret)),
}
for _, provider := range bouquins.Providers {
app.OAuthConf[provider.Name()] = provider.Config(conf)
}
err = app.PrepareAll() err = app.PrepareAll()
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
assets(conf.CalibrePath)
router(app) router(app)
return conf return app
} }
func assets(calibre string) { func assets(calibre string) {
http.Handle(bouquins.URLJs, http.StripPrefix("/"+bouquins.Version, http.FileServer(http.Dir("assets")))) http.Handle(bouquins.URLJs, http.StripPrefix("/"+bouquins.Version, http.FileServer(http.Dir("assets"))))
http.Handle(bouquins.URLCss, http.StripPrefix("/"+bouquins.Version, http.FileServer(http.Dir("assets")))) http.Handle(bouquins.URLCss, http.StripPrefix("/"+bouquins.Version, http.FileServer(http.Dir("assets"))))
http.Handle(bouquins.URLFonts, http.StripPrefix("/"+bouquins.Version, http.FileServer(http.Dir("assets")))) http.Handle(bouquins.URLFonts, http.StripPrefix("/"+bouquins.Version, http.FileServer(http.Dir("assets"))))
http.Handle(bouquins.URLCalibre, http.StripPrefix(bouquins.URLCalibre, http.FileServer(http.Dir(calibre))))
} }
func handle(f func(res http.ResponseWriter, req *http.Request) error) func(res http.ResponseWriter, req *http.Request) { func handle(f func(res http.ResponseWriter, req *http.Request) error) func(res http.ResponseWriter, req *http.Request) {
@ -97,7 +106,12 @@ func handleURL(url string, f func(res http.ResponseWriter, req *http.Request) er
} }
func router(app *bouquins.Bouquins) { func router(app *bouquins.Bouquins) {
assets(app.Conf.CalibrePath)
http.Handle(bouquins.URLCalibre, app.CalibreFileServer())
handleURL(bouquins.URLIndex, app.IndexPage) handleURL(bouquins.URLIndex, app.IndexPage)
handleURL(bouquins.URLLogin, app.LoginPage)
handleURL(bouquins.URLLogout, app.LogoutPage)
handleURL(bouquins.URLCallback, app.CallbackPage)
handleURL(bouquins.URLBooks, app.BooksPage) handleURL(bouquins.URLBooks, app.BooksPage)
handleURL(bouquins.URLAuthors, app.AuthorsPage) handleURL(bouquins.URLAuthors, app.AuthorsPage)
handleURL(bouquins.URLSeries, app.SeriesPage) handleURL(bouquins.URLSeries, app.SeriesPage)
@ -106,7 +120,8 @@ func router(app *bouquins.Bouquins) {
} }
func main() { func main() {
conf := initApp() app := initApp()
defer db.Close() defer app.DB.Close()
http.ListenAndServe(conf.BindAddress, nil) defer app.UserDB.Close()
http.ListenAndServe(app.Conf.BindAddress, nil)
} }

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="stylesheet" href="{{ assetUrl "bootstrap" "css" }}"> <link rel="stylesheet" href="{{ assetUrl "bootstrap" "css" }}">
<link rel="stylesheet" href="{{ assetUrl "bouquins" "css" }}">
<link rel="preload" href="{{ assetUrl "vue" "js" }}" as="script"> <link rel="preload" href="{{ assetUrl "vue" "js" }}" as="script">
<link rel="prefetch" href="{{ assetUrl "vue" "js" }}"> <link rel="prefetch" href="{{ assetUrl "vue" "js" }}">
<link rel="preload" href="{{ assetUrl "bouquins" "js" }}" as="script"> <link rel="preload" href="{{ assetUrl "bouquins" "js" }}" as="script">
@ -23,5 +24,12 @@
<input name="q" type="text" class="form-control" placeholder="Recherche"> <input name="q" type="text" class="form-control" placeholder="Recherche">
</div> </div>
</form> </form>
<ul class="nav navbar-nav navbar-right">
{{ if .Username }}
<li><a href="/logout">{{ .Username }} <span title="Déconnexion" class="glyphicon glyphicon-log-out"></span></a></li>
{{ else }}
<li><a href="/login">Connexion <span class="glyphicon glyphicon-log-in"></span></a></li>
{{ end }}
</ul>
</div> </div>
</nav> </nav>

12
templates/provider.html Normal file
View File

@ -0,0 +1,12 @@
{{ template "header.html" . }}
<div class="container" id="provider">
<div class="jumbotron">
<h1>Connexion</h1>
<p>Pour vous connecter, vous devez disposer d'un compte et vous authentifier chez un des fournisseurs ci-dessous. En cliquant sur un fournisseur, vous allez être redirigé vers une page d'authentification de ce fournisseur.</p>
{{ range .Providers }}
<!-- TODO icon -->
<a class="btn btn-default btn-lg" role="button" href="/login?provider={{ .Name }}">{{ if .Icon }}<span class="providericon {{ .Icon }}"></span>&nbsp;{{ end }}{{ .Label }}</a>
{{ end }}
</ul>
</div>
{{ template "footer.html" . }}