Merge branch 'oauth2'
This commit is contained in:
commit
95aab372a5
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,3 +5,4 @@ calibre.db
|
|||||||
bouquins.json
|
bouquins.json
|
||||||
Gopkg.lock
|
Gopkg.lock
|
||||||
vendor/
|
vendor/
|
||||||
|
users.db
|
||||||
|
37
README.md
37
README.md
@ -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
14
assets/css/bouquins.css
Normal 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
1
assets/css/bouquins.min.css
vendored
Normal 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
138
bouquins/auth.go
Normal 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)
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -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
11
bouquins/dbusers.go
Normal 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
94
bouquins/github.go
Normal 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
83
bouquins/google.go
Normal 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
57
main.go
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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
12
templates/provider.html
Normal 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> {{ end }}{{ .Label }}</a>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ template "footer.html" . }}
|
Loading…
Reference in New Issue
Block a user