Improve oauth integration (#21266)
This commit is contained in:
parent
4c73cbe197
commit
11e769ea22
18 changed files with 219 additions and 208 deletions
|
|
@ -37,6 +37,12 @@ const preview = {
|
|||
setup((app) => {
|
||||
app.config.globalProperties.$hiddenFeatures = () => true;
|
||||
app.use(setupI18n());
|
||||
|
||||
// Mock router-link for Storybook
|
||||
app.component("router-link", {
|
||||
props: ["to", "activeClass"],
|
||||
template: '<a :href="to"><slot /></a>',
|
||||
});
|
||||
});
|
||||
|
||||
export default preview;
|
||||
|
|
|
|||
10
api/api.go
10
api/api.go
|
|
@ -3,7 +3,7 @@ package api
|
|||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -193,9 +193,11 @@ type Tariff interface {
|
|||
|
||||
// AuthProvider is the ability to provide OAuth authentication through the ui
|
||||
type AuthProvider interface {
|
||||
HandleCallback(r *http.Request)
|
||||
HandleLogout(r *http.Request)
|
||||
AuthCodeURL(state string) string
|
||||
Login(state string) string
|
||||
Logout() error
|
||||
HandleCallback(responseValues url.Values) error
|
||||
Authenticated() bool
|
||||
DisplayName() string
|
||||
}
|
||||
|
||||
// IconDescriber optionally provides an icon
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ import collector from "@/mixins/collector.ts";
|
|||
import WelcomeIcons from "./WelcomeIcons.vue";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import type {
|
||||
Auth,
|
||||
AuthProviders,
|
||||
Battery,
|
||||
CURRENCY,
|
||||
Forecast,
|
||||
|
|
@ -133,9 +133,7 @@ export default defineComponent({
|
|||
bufferStartSoc: Number,
|
||||
siteTitle: String,
|
||||
vehicles: Object,
|
||||
|
||||
auth: { type: Object as PropType<Auth>, default: () => ({ vehicles: {} }) },
|
||||
|
||||
authProviders: { type: Object as PropType<AuthProviders>, default: () => ({}) },
|
||||
currency: { type: String as PropType<CURRENCY> },
|
||||
statistics: Object,
|
||||
tariffFeedIn: Number,
|
||||
|
|
@ -195,7 +193,7 @@ export default defineComponent({
|
|||
return Object.entries(vehicles).map(([name, vehicle]) => ({ name, ...vehicle }));
|
||||
},
|
||||
topNavigation() {
|
||||
return { vehicleLogins: this.auth.vehicles, ...this.collectProps(Navigation) };
|
||||
return this.collectProps(Navigation);
|
||||
},
|
||||
showParkingLot() {
|
||||
// work in progess
|
||||
|
|
|
|||
|
|
@ -43,9 +43,8 @@ export default defineComponent({
|
|||
title: String,
|
||||
},
|
||||
computed: {
|
||||
topNavigation() {
|
||||
const vehicleLogins = store.state.auth ? store.state.auth.vehicles : {};
|
||||
return { vehicleLogins, ...this.collectProps(Navigation, store.state) };
|
||||
topNavigation(): any {
|
||||
return this.collectProps(Navigation, store.state);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export default {
|
|||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
vehicleLogins: { control: "object" },
|
||||
authProviders: { control: "object" },
|
||||
sponsor: { control: "object" },
|
||||
},
|
||||
} as Meta<typeof Navigation>;
|
||||
|
|
@ -24,30 +24,30 @@ const Template: StoryFn<typeof Navigation> = (args) => ({
|
|||
export const Standard = Template.bind({});
|
||||
Standard.args = {};
|
||||
|
||||
export const VehicleLogins = Template.bind({});
|
||||
VehicleLogins.args = {
|
||||
vehicleLogins: {
|
||||
export const OAuthStatus = Template.bind({});
|
||||
OAuthStatus.args = {
|
||||
authProviders: {
|
||||
"Mercedes EQS": {
|
||||
authenticated: true,
|
||||
uri: "https://login-provider-a.test/",
|
||||
id: "mercedes-eqs-9oqwjdf9oqwjd",
|
||||
},
|
||||
"Nissan Leaf Pro": {
|
||||
authenticated: true,
|
||||
uri: "https://login-provider-b.test/",
|
||||
id: "nissan-leaf-pro-9oqwjdf9oqwjd",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const PendingVehicleLogins = Template.bind({});
|
||||
PendingVehicleLogins.args = {
|
||||
vehicleLogins: {
|
||||
export const PendingOAuthStatus = Template.bind({});
|
||||
PendingOAuthStatus.args = {
|
||||
authProviders: {
|
||||
"Mercedes EQS": {
|
||||
authenticated: true,
|
||||
uri: "https://login-provider-a.test/",
|
||||
id: "mercedes-eqs-9oqwjdf9oqwjd",
|
||||
},
|
||||
"Nissan Leaf Pro": {
|
||||
authenticated: false,
|
||||
uri: "https://login-provider-b.test/",
|
||||
id: "nissan-leaf-pro-9oqwjdf9oqwjd",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
data-testid="topnavigation-button"
|
||||
>
|
||||
<span
|
||||
v-if="showBadge"
|
||||
v-if="showRootBadge"
|
||||
class="position-absolute top-0 start-100 translate-middle p-2 rounded-circle"
|
||||
:class="badgeClass"
|
||||
>
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
<li>
|
||||
<router-link class="dropdown-item" to="/config" active-class="active">
|
||||
<span
|
||||
v-if="showBadge"
|
||||
v-if="showConfigBadge"
|
||||
class="d-inline-block p-1 rounded-circle bg-warning rounded-circle"
|
||||
:class="badgeClass"
|
||||
></span>
|
||||
|
|
@ -75,9 +75,8 @@
|
|||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<template v-if="providerLogins.length > 0">
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<h6 class="dropdown-header">{{ $t("header.login") }}</h6>
|
||||
<h6 class="dropdown-header">{{ $t("header.authProviders.title") }}</h6>
|
||||
</li>
|
||||
<li v-for="l in providerLogins" :key="l.title">
|
||||
<button
|
||||
|
|
@ -86,14 +85,13 @@
|
|||
@click="handleProviderAuthorization(l)"
|
||||
>
|
||||
<span
|
||||
v-if="!l.loggedIn"
|
||||
class="d-inline-block p-1 rounded-circle border border-light rounded-circle"
|
||||
:class="badgeClass"
|
||||
:class="l.authenticated ? 'bg-success' : 'bg-warning'"
|
||||
></span>
|
||||
{{ l.title }}
|
||||
{{ $t(l.loggedIn ? "main.provider.logout" : "main.provider.login") }}
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
</template>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item" @click="openHelpModal">
|
||||
|
|
@ -136,25 +134,15 @@ import baseAPI from "./baseapi";
|
|||
import { isApp, sendToApp } from "@/utils/native";
|
||||
import { isUserConfigError } from "@/utils/fatal";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import type { FatalError, Sponsor, VehicleLogins } from "@/types/evcc";
|
||||
import type { FatalError, Sponsor, AuthProviders } from "@/types/evcc";
|
||||
import type { Provider as Provider } from "./types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "TopNavigation",
|
||||
mixins: [collector],
|
||||
props: {
|
||||
vehicleLogins: {
|
||||
type: Object as PropType<VehicleLogins>,
|
||||
default: () => {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
sponsor: {
|
||||
type: Object as PropType<Sponsor>,
|
||||
default: () => {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
authProviders: { type: Object as PropType<AuthProviders>, default: () => ({}) },
|
||||
sponsor: { type: Object as PropType<Sponsor>, default: () => ({}) },
|
||||
forecast: Object,
|
||||
battery: Array,
|
||||
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
|
||||
|
|
@ -169,23 +157,23 @@ export default defineComponent({
|
|||
batteryConfigured() {
|
||||
return this.battery?.length;
|
||||
},
|
||||
logoutCount() {
|
||||
return this.providerLogins.filter((login) => !login.loggedIn).length;
|
||||
},
|
||||
providerLogins(): Provider[] {
|
||||
return Object.entries(this.vehicleLogins).map(([k, v]) => ({
|
||||
title: k,
|
||||
loggedIn: v.authenticated,
|
||||
loginPath: v.uri + "/login",
|
||||
logoutPath: v.uri + "/logout",
|
||||
return Object.entries(this.authProviders).map(([title, { authenticated, id }]) => ({
|
||||
title,
|
||||
authenticated,
|
||||
loginPath: "providerauth/login?id=" + id,
|
||||
logoutPath: "providerauth/logout?id=" + id,
|
||||
}));
|
||||
},
|
||||
loginRequired() {
|
||||
return this.logoutCount > 0;
|
||||
return Object.values(this.authProviders).some((p) => !p.authenticated);
|
||||
},
|
||||
showBadge() {
|
||||
showConfigBadge() {
|
||||
const userConfigError = isUserConfigError(this.fatal);
|
||||
return this.loginRequired || this.sponsor.expiresSoon || userConfigError;
|
||||
return this.sponsor.expiresSoon || userConfigError;
|
||||
},
|
||||
showRootBadge() {
|
||||
return this.loginRequired || this.showConfigBadge;
|
||||
},
|
||||
badgeClass() {
|
||||
if (this.fatal.length > 0) {
|
||||
|
|
@ -218,12 +206,24 @@ export default defineComponent({
|
|||
},
|
||||
methods: {
|
||||
async handleProviderAuthorization(provider: Provider) {
|
||||
if (!provider.loggedIn) {
|
||||
baseAPI.post(provider.loginPath).then(function (response) {
|
||||
const { title, authenticated, loginPath, logoutPath } = provider;
|
||||
if (!authenticated) {
|
||||
try {
|
||||
const response = await baseAPI.get(loginPath);
|
||||
window.location.href = response.data.loginUri;
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
alert(`Failed to login: ${error.response?.data}`);
|
||||
}
|
||||
} else {
|
||||
baseAPI.post(provider.logoutPath);
|
||||
if (window.confirm(this.$t("header.authProviders.confirmLogout", { title }))) {
|
||||
try {
|
||||
await baseAPI.get(logoutPath);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
alert(`Failed to logout: ${error.response?.data}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
openSettingsModal() {
|
||||
|
|
|
|||
2
assets/js/components/Top/types.d.ts
vendored
2
assets/js/components/Top/types.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
|||
export interface Provider {
|
||||
title: string;
|
||||
loggedIn: boolean;
|
||||
authenticated: boolean;
|
||||
loginPath: string;
|
||||
logoutPath: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,7 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
export interface Auth {
|
||||
vehicles: VehicleLogins;
|
||||
}
|
||||
|
||||
export type VehicleLogins = Record<string, { authenticated: boolean; uri: string }>;
|
||||
export type AuthProviders = Record<string, { id: string; authenticated: boolean }>;
|
||||
|
||||
export interface MqttConfig {
|
||||
broker: string;
|
||||
|
|
@ -54,7 +50,7 @@ export interface State {
|
|||
forecast?: Forecast;
|
||||
currency?: CURRENCY;
|
||||
fatal?: FatalError[];
|
||||
auth?: Auth;
|
||||
providerAuth?: AuthProviders;
|
||||
version?: string;
|
||||
battery?: Battery[];
|
||||
tariffGrid?: number;
|
||||
|
|
|
|||
|
|
@ -314,7 +314,7 @@ func runRoot(cmd *cobra.Command, args []string) {
|
|||
}()
|
||||
|
||||
// allow web access for vehicles
|
||||
configureAuth(httpd.Router())
|
||||
configureAuth(httpd.Router(), valueChan)
|
||||
|
||||
authObject := auth.New()
|
||||
if ok, _ := cmd.Flags().GetBool(flagDisableAuth); ok {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import (
|
|||
"github.com/evcc-io/evcc/server/db/settings"
|
||||
"github.com/evcc-io/evcc/server/eebus"
|
||||
"github.com/evcc-io/evcc/server/modbus"
|
||||
"github.com/evcc-io/evcc/server/oauth2redirect"
|
||||
"github.com/evcc-io/evcc/server/providerauth"
|
||||
"github.com/evcc-io/evcc/tariff"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/config"
|
||||
|
|
@ -1117,13 +1117,13 @@ func configureLoadpoints(conf globalconfig.All) error {
|
|||
}
|
||||
|
||||
// configureAuth handles routing for devices. For now only api.AuthProvider related routes
|
||||
func configureAuth(router *mux.Router) {
|
||||
auth := router.PathPrefix("/oauth").Subrouter()
|
||||
func configureAuth(router *mux.Router, paramC chan<- util.Param) {
|
||||
auth := router.PathPrefix("/providerauth").Subrouter()
|
||||
auth.Use(handlers.CompressHandler)
|
||||
auth.Use(handlers.CORS(
|
||||
handlers.AllowedHeaders([]string{"Content-Type"}),
|
||||
))
|
||||
|
||||
// wire the handler
|
||||
oauth2redirect.SetupRouter(auth)
|
||||
providerauth.Setup(auth, paramC)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,4 +20,5 @@ const (
|
|||
Telemetry = "telemetry"
|
||||
DemoMode = "demoMode"
|
||||
AuthDisabled = "authDisabled"
|
||||
ProviderAuth = "providerAuth"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -620,10 +620,13 @@
|
|||
},
|
||||
"header": {
|
||||
"about": "Über",
|
||||
"authProviders": {
|
||||
"confirmLogout": "Sicher, dass du {title} trennen möchtest?",
|
||||
"title": "Autorisierungsstatus"
|
||||
},
|
||||
"blog": "Blog",
|
||||
"docs": "Dokumentation",
|
||||
"github": "GitHub",
|
||||
"login": "Fahrzeug-Logins",
|
||||
"logout": "Abmelden",
|
||||
"nativeSettings": "Server ändern",
|
||||
"needHelp": "Hilfe benötigt?",
|
||||
|
|
|
|||
|
|
@ -620,10 +620,13 @@
|
|||
},
|
||||
"header": {
|
||||
"about": "About",
|
||||
"authProviders": {
|
||||
"confirmLogout": "Are you sure you want to disconnect {title}?",
|
||||
"title": "Authorization Status"
|
||||
},
|
||||
"blog": "Blog",
|
||||
"docs": "Documentation",
|
||||
"github": "GitHub",
|
||||
"login": "Vehicle Logins",
|
||||
"logout": "Logout",
|
||||
"nativeSettings": "Change Server",
|
||||
"needHelp": "Need Help?",
|
||||
|
|
|
|||
|
|
@ -1,20 +1,19 @@
|
|||
package auth
|
||||
|
||||
// TODO
|
||||
// - configurable redirect uri
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/server/db/settings"
|
||||
"github.com/evcc-io/evcc/server/oauth2redirect"
|
||||
"github.com/evcc-io/evcc/server/providerauth"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/oauth"
|
||||
"golang.org/x/oauth2"
|
||||
|
|
@ -31,7 +30,7 @@ type OAuth struct {
|
|||
}
|
||||
|
||||
var (
|
||||
oauthMu sync.Mutex
|
||||
// oauthMu sync.Mutex
|
||||
identities = make(map[string]*OAuth)
|
||||
)
|
||||
|
||||
|
|
@ -43,7 +42,7 @@ func addInstance(subject string, identity *OAuth) {
|
|||
identities[subject] = identity
|
||||
}
|
||||
|
||||
func init() {
|
||||
/* func init() {
|
||||
registry.AddCtx("oauth", NewOauthFromConfig)
|
||||
}
|
||||
|
||||
|
|
@ -57,23 +56,24 @@ func NewOauthFromConfig(ctx context.Context, other map[string]any) (Authorizer,
|
|||
}
|
||||
|
||||
return NewOauth(ctx, cc)
|
||||
}
|
||||
} */
|
||||
|
||||
func NewOauth(ctx context.Context, cc oauth2.Config) (*OAuth, error) {
|
||||
func NewOauth(ctx context.Context, cc oauth2.Config, instanceName string) (*OAuth, error) {
|
||||
log := util.NewLogger("oauth-generic")
|
||||
|
||||
// generate json string from oauth2 config
|
||||
bytejson, err := json.Marshal(cc)
|
||||
|
||||
if err != nil {
|
||||
log.ERROR.Printf("error converting oauth config to json: %s", err)
|
||||
if instanceName == "" {
|
||||
return nil, errors.New("instance name must not be empty")
|
||||
}
|
||||
|
||||
// generate json string from oauth2 config
|
||||
bytejson, _ := json.Marshal(cc)
|
||||
|
||||
h := sha256.New()
|
||||
h.Write(bytejson)
|
||||
sha1_hash := hex.EncodeToString(h.Sum(nil))
|
||||
fullHash := hex.EncodeToString(h.Sum(nil))
|
||||
sha256_hash := fullHash[:8]
|
||||
|
||||
subject := sha1_hash
|
||||
subject := instanceName + " (" + sha256_hash + ")"
|
||||
|
||||
// reuse instance
|
||||
if instance := getInstance(subject); instance != nil {
|
||||
|
|
@ -104,7 +104,7 @@ func NewOauth(ctx context.Context, cc oauth2.Config) (*OAuth, error) {
|
|||
addInstance(o.subject, o)
|
||||
|
||||
// register authredirect
|
||||
oauth2redirect.Register(o, subject)
|
||||
providerauth.Register(o, subject)
|
||||
|
||||
return o, nil
|
||||
}
|
||||
|
|
@ -119,9 +119,6 @@ func (o *OAuth) Transport(base http.RoundTripper) http.RoundTripper {
|
|||
|
||||
// RefreshToken implements oauth.RefreshTokenSource.
|
||||
func (o *OAuth) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
if token.RefreshToken == "" {
|
||||
return nil, api.ErrMissingToken
|
||||
}
|
||||
|
|
@ -144,19 +141,9 @@ func (o *OAuth) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
|
|||
return token, err
|
||||
}
|
||||
|
||||
// AuthCodeURL implements api.AuthProvider.
|
||||
func (o *OAuth) AuthCodeURL(state string) string {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
o.cv = oauth2.GenerateVerifier()
|
||||
return o.cc.AuthCodeURL(state, oauth2.S256ChallengeOption(o.cv))
|
||||
}
|
||||
|
||||
// HandleCallback implements api.AuthProvider.
|
||||
func (o *OAuth) HandleCallback(r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
code := q.Get("code")
|
||||
func (o *OAuth) HandleCallback(responseValues url.Values) error {
|
||||
code := responseValues.Get("code")
|
||||
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
|
@ -164,7 +151,7 @@ func (o *OAuth) HandleCallback(r *http.Request) {
|
|||
token, err := o.cc.Exchange(o.ctx, code, oauth2.VerifierOption(o.cv))
|
||||
if err != nil {
|
||||
o.log.ERROR.Printf("error during oauth exchange: %s", err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
err = settings.SetJson(o.subject, token)
|
||||
if err != nil {
|
||||
|
|
@ -172,10 +159,20 @@ func (o *OAuth) HandleCallback(r *http.Request) {
|
|||
}
|
||||
|
||||
o.TokenSource = oauth.RefreshTokenSource(token, o)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleLogout implements api.AuthProvider.
|
||||
func (o *OAuth) HandleLogout(r *http.Request) {
|
||||
// Login implements api.AuthProvider.
|
||||
func (o *OAuth) Login(state string) string {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
o.cv = oauth2.GenerateVerifier()
|
||||
return o.cc.AuthCodeURL(state, oauth2.S256ChallengeOption(o.cv))
|
||||
}
|
||||
|
||||
// Logout implements api.AuthProvider.
|
||||
func (o *OAuth) Logout() error {
|
||||
o.log.INFO.Printf("removing %s from database", o.subject)
|
||||
if settings.Exists(o.subject) {
|
||||
settings.Delete(o.subject)
|
||||
|
|
@ -184,4 +181,20 @@ func (o *OAuth) HandleLogout(r *http.Request) {
|
|||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
o.TokenSource = oauth.RefreshTokenSource(nil, o)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisplayName implements api.AuthProvider.
|
||||
func (o *OAuth) DisplayName() string {
|
||||
return o.subject
|
||||
}
|
||||
|
||||
// Authenticated implements api.AuthProvider.
|
||||
func (o *OAuth) Authenticated() bool {
|
||||
// check if token is valid
|
||||
if token, err := o.TokenSource.Token(); err == nil {
|
||||
return token.Valid()
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
package oauth2redirect
|
||||
package providerauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/keys"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
|
@ -28,6 +31,11 @@ type Handler struct {
|
|||
log *util.Logger
|
||||
}
|
||||
|
||||
type AuthProvider struct {
|
||||
ID string `json:"id"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
var secret [16]byte
|
||||
_, err := io.ReadFull(rand.Reader, secret[:])
|
||||
|
|
@ -37,39 +45,74 @@ func init() {
|
|||
}
|
||||
|
||||
instance = &Handler{
|
||||
mu: sync.Mutex{},
|
||||
secret: secret[:],
|
||||
providers: make(map[string]api.AuthProvider),
|
||||
states: make(map[string]string),
|
||||
log: util.NewLogger("oauth2redirect"),
|
||||
log: util.NewLogger("providerauth"),
|
||||
}
|
||||
}
|
||||
|
||||
// SetupRouter connects the redirect handler to the router
|
||||
func SetupRouter(router *mux.Router) {
|
||||
// Setup connects the redirect handler to the router and registers the callback channel
|
||||
func Setup(router *mux.Router, paramC chan<- util.Param) {
|
||||
// callback?code=...&state=...
|
||||
router.Methods(http.MethodGet).Path("/callback").HandlerFunc(instance.handleCallback)
|
||||
// login?id=...
|
||||
router.Methods(http.MethodGet).Path("/login").HandlerFunc(instance.handleLogin)
|
||||
// logout?id=...
|
||||
router.Methods(http.MethodGet).Path("/logout").HandlerFunc(instance.handleLogout)
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
instance.Publish(paramC)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *Handler) Publish(paramC chan<- util.Param) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
apMap := make(map[string]*AuthProvider)
|
||||
|
||||
for id, provider := range a.providers {
|
||||
ap := &AuthProvider{
|
||||
ID: url.QueryEscape(id),
|
||||
Authenticated: provider.Authenticated(),
|
||||
}
|
||||
apMap[provider.DisplayName()] = ap
|
||||
}
|
||||
|
||||
val := struct {
|
||||
AuthProviders map[string]*AuthProvider `json:"authProviders,omitempty"`
|
||||
}{
|
||||
AuthProviders: apMap,
|
||||
}
|
||||
|
||||
a.log.DEBUG.Printf("publishing %d auth providers", len(apMap))
|
||||
|
||||
// publish the updated auth providers
|
||||
paramC <- util.Param{Key: keys.ProviderAuth, Val: val}
|
||||
}
|
||||
|
||||
// Register registers a specific AuthProvider. Returns login path as string.
|
||||
func Register(handler api.AuthProvider, name string) (string, error) {
|
||||
func Register(handler api.AuthProvider, name string) error {
|
||||
return instance.register(handler, name)
|
||||
}
|
||||
|
||||
func (a *Handler) register(handler api.AuthProvider, name string) (string, error) {
|
||||
func (a *Handler) register(handler api.AuthProvider, name string) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.providers[name] != nil {
|
||||
a.log.ERROR.Printf("provider with name %s already registered", name)
|
||||
return "", errors.New("provider already registered")
|
||||
return errors.New("provider already registered")
|
||||
}
|
||||
a.log.INFO.Printf("registering oauth provider at /oauth/login?id=%s", name)
|
||||
a.log.INFO.Printf("registering oauth provider: %s", name)
|
||||
a.providers[name] = handler
|
||||
return "/oauth/login?id=" + name, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -82,6 +125,8 @@ func (a *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
a.log.DEBUG.Printf("login request for provider: %s", id)
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
|
|
@ -106,14 +151,17 @@ func (a *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||
}(encryptedState)
|
||||
|
||||
// Build authorization URL
|
||||
loginURL := provider.AuthCodeURL(encryptedState)
|
||||
if loginURL == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "invalid login URL")
|
||||
return
|
||||
}
|
||||
loginUri := provider.Login(encryptedState)
|
||||
|
||||
http.Redirect(w, r, loginURL, http.StatusFound)
|
||||
responseVal := struct {
|
||||
LoginUri string `json:"loginUri"`
|
||||
}{
|
||||
LoginUri: loginUri,
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(responseVal); err != nil {
|
||||
a.log.ERROR.Printf("failed to encode login URI response: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -137,7 +185,9 @@ func (a *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Handle logout
|
||||
provider.HandleLogout(r)
|
||||
if err := provider.Logout(); err != nil {
|
||||
a.log.ERROR.Printf("logout for provider %s failed: %v", id, err)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
|
@ -187,7 +237,12 @@ func (a *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|||
delete(a.states, encryptedState)
|
||||
|
||||
// Handle the callback
|
||||
provider.HandleCallback(r)
|
||||
if err := provider.HandleCallback(r.URL.Query()); err != nil {
|
||||
a.log.ERROR.Printf("callback handling for provider %s failed: %v", id, err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, "callback handling failed")
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
|
@ -2,25 +2,24 @@ template: volvo-connected
|
|||
products:
|
||||
- brand: Volvo
|
||||
requirements:
|
||||
evcc: ["skiptest"]
|
||||
description:
|
||||
de: |
|
||||
Für die Nutzung mit EVCC benötigst du einen Volvo Account und einen Volvo Connected Car API Key.
|
||||
Erstelle dazu auf der [Account Seite](https://developer.volvocars.com/account/) eine neue Applikation und speichere den primären VCC API Key ab.
|
||||
Veröffentliche nun deine Applikation und wähle unter "Scopes" die Berechtigungen "Connected Vehicle API -> conve:vehicle-relation" + "conve:odometer-status" und "Energy API -> energy:state:read" aus.
|
||||
Als Redirect URL musst du die URL deiner EVCC Instanz eintragen, zb "https://evcc.example.org/oauth/callback".
|
||||
Sobald die Applikation erstellt ist, wird sie als "Publication under Review" angezeigt. Das ist nicht weiter schlimm, es funktioniert trotzdem.
|
||||
Beim Anlegen des Fahrzeugs über die UI wird ein Fehler angezeigt.
|
||||
Schaue im Log nach der Meldung "registering oauth provider at /oauth/login?..." und öffne den Link "https://evcc.example.org/oauth/login?..." in einem neuen Tab.
|
||||
Melde dich mit deinem Volvo Account an und erlaube den Zugriff auf die Daten. Ist die Autorisierung erfolgreich, kann das Fahrzeug hinzugefügt werden.
|
||||
Für die Nutzung mit evcc benötigst du einen Volvo Account und einen Volvo Connected Car API Key.
|
||||
1. Erstelle dazu auf der [Account Seite](https://developer.volvocars.com/account/) eine neue Applikation und speichere den primären VCC API Key ab.
|
||||
2. Veröffentliche nun deine Applikation und wähle unter **Scopes** folgende Berechtigungen **Connected Vehicle API:** `conve:vehicle-relation, conve:odometer-status`, **Energy API:** `energy:state:read`
|
||||
3. Als Redirect URL musst du die URL deiner evcc Instanz eintragen, zb `https://evcc.example.org/providerauth/callback`.
|
||||
4. Beim Anlegen des Fahrzeugs über die UI wird ein Fehler angezeigt.
|
||||
5. Öffne einen neuen Tab und gehe auf die evcc Konfigurationsseite. Im Menü oben rechts wird ein Knopf zum Anmelden angezeigt.
|
||||
6. Melde dich mit deinem Volvo Account an und erlaube den Zugriff auf die Daten. Ist die Autorisierung erfolgreich, kann das Fahrzeug hinzugefügt werden.
|
||||
en: |
|
||||
To use with EVCC, you need a Volvo account and a Volvo Connected Car API Key.
|
||||
To do this, create a new application on the [Account page](https://developer.volvocars.com/account/) and save the primary VCC API key.
|
||||
Now publish your application and select the permissions "Connected Vehicle API -> conve:vehicle-relation" + "conve:odometer-status" and "Energy API -> energy:state:read" under "Scopes".
|
||||
You must enter the URL of your EVCC instance as the redirect URL, e.g. "https://evcc.example.org/oauth/callback".
|
||||
Once the application is created, it will be displayed as "Publication under Review". This is not a problem, it still works.
|
||||
When adding the vehicle via the UI, an error message is displayed.
|
||||
Check the log for the message "registering oauth provider at /oauth/login?..." and open the link "https://evcc.example.org/oauth/login?..." in a new tab.
|
||||
Log in with your Volvo account and allow access to the data. If the authorization is successful, the vehicle can be added.
|
||||
To use with evcc, you need a Volvo account and a Volvo Connected Car API Key.
|
||||
1. To do this, create a new application on the [Account page](https://developer.volvocars.com/account/) and save the primary VCC API key.
|
||||
2. Now publish your application and select the **Scope** permissions **Connected Vehicle API:** `conve:vehicle-relation, conve:odometer-status`, **Energy API:** `energy:state:read`.
|
||||
3. You must enter the URL of your evcc instance as the redirect URL, e.g. `https://evcc.example.org/providerauth/callback`.
|
||||
4. When adding the vehicle via the UI, an error message is displayed.
|
||||
5. Open a new tab and go to the evcc configuration page. A button for logging in will appear in the top right menu.
|
||||
6. Log in with your Volvo account and allow access to the data. If the authorization is successful, the vehicle can be added.
|
||||
params:
|
||||
- preset: vehicle-common
|
||||
- name: vccapikey
|
||||
|
|
@ -45,8 +44,9 @@ params:
|
|||
description:
|
||||
generic: Redirect URI
|
||||
help:
|
||||
en: "Redirect URI of your EVCC instance, format: `https://evcc.example.org/oauth/callback`. Must match the redirect URI set in your Volvo Developer App."
|
||||
de: "Redirect-URI deiner EVCC-Instanz, Format: https://evcc.example.org/oauth/callback. Muss mit der Redirect-URI übereinstimmen, die in deiner Volvo Developer App festgelegt ist."
|
||||
en: "Redirect URI of your evcc instance. Must match the redirect URI set in your Volvo Developer App."
|
||||
de: "Redirect-URI deiner evcc-Instanz. Muss mit der Redirect-URI übereinstimmen, die in deiner Volvo Developer App festgelegt ist."
|
||||
example: "https://evcc.example.org/providerauth/callback"
|
||||
- name: vin
|
||||
example: WF0FXX...
|
||||
- name: accessToken
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
package util
|
||||
|
||||
import "sync"
|
||||
|
||||
type AuthCollection struct {
|
||||
mu sync.Mutex
|
||||
paramC chan<- Param
|
||||
vehicles map[string]*AuthProvider
|
||||
}
|
||||
|
||||
func NewAuthCollection(paramC chan<- Param) *AuthCollection {
|
||||
return &AuthCollection{
|
||||
paramC: paramC,
|
||||
vehicles: make(map[string]*AuthProvider),
|
||||
}
|
||||
}
|
||||
|
||||
func (ac *AuthCollection) Register(baseURI, title string) *AuthProvider {
|
||||
ap := &AuthProvider{
|
||||
ac: ac,
|
||||
Uri: baseURI,
|
||||
}
|
||||
|
||||
ac.mu.Lock()
|
||||
ac.vehicles[title] = ap
|
||||
ac.mu.Unlock()
|
||||
|
||||
return ap
|
||||
}
|
||||
|
||||
// publish routes and status
|
||||
func (ac *AuthCollection) Publish() {
|
||||
ac.mu.Lock()
|
||||
defer ac.mu.Unlock()
|
||||
|
||||
val := struct {
|
||||
Vehicles map[string]*AuthProvider `json:"vehicles,omitempty"`
|
||||
}{
|
||||
Vehicles: ac.vehicles,
|
||||
}
|
||||
|
||||
// TODO registered global key name
|
||||
ac.paramC <- Param{Key: "auth", Val: val}
|
||||
}
|
||||
|
||||
type AuthProvider struct {
|
||||
ac *AuthCollection
|
||||
Uri string `json:"uri"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
}
|
||||
|
||||
func (ap *AuthProvider) Handler() chan<- bool {
|
||||
c := make(chan bool)
|
||||
|
||||
go func() {
|
||||
for auth := range c {
|
||||
ap.ac.mu.Lock()
|
||||
ap.Authenticated = auth
|
||||
ap.ac.mu.Unlock()
|
||||
ap.ac.Publish()
|
||||
}
|
||||
}()
|
||||
|
||||
return c
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ func NewVolvoConnectedFromConfig(other map[string]interface{}) (api.Vehicle, err
|
|||
// create oauth2 config
|
||||
config := connected.Oauth2Config(cc.Credentials.ID, cc.Credentials.Secret, cc.RedirectUri)
|
||||
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewClient(log))
|
||||
authorizer, err := auth.NewOauth(ctx, *config)
|
||||
authorizer, err := auth.NewOauth(ctx, *config, cc.embed.GetTitle())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue