Skip to content

Commit 450b529

Browse files
wip login stuff
1 parent 73e7bcd commit 450b529

14 files changed

+351
-24
lines changed

doc/.pubignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*

example/aha_client_example.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@ Future<void> main() async {
2424
final client = AhaClient();
2525

2626
final response = await client.login.getLoginStatus();
27-
2827
print(response.body);
2928

29+
final response2 = await client.aha.get('getdevicelistinfos');
30+
print(response2.statusCode);
31+
print(response2.headers);
32+
print(response2.bodyString);
33+
3034
client.dispose();
3135
}

lib/src/.pubignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Generated files
2+
!*.chopper.dart
3+
!*.freezed.dart

lib/src/api/aha_client.dart

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import 'package:aha_client/src/api/login_service.dart';
2-
import 'package:aha_client/src/api/models/session_info.dart';
3-
import 'package:aha_client/src/api/xml_typed_converter.dart';
41
import 'package:chopper/chopper.dart';
52

3+
import 'aha_service.dart';
4+
import 'login/login_service.dart';
5+
import 'login/models/session_info.dart';
6+
import 'xml_typed_converter.dart';
7+
68
class AhaClient {
79
static const defaultHostName = 'fritz.box';
810

@@ -21,10 +23,13 @@ class AhaClient {
2123
..registerConverter<SessionInfo>(SessionInfo.converter),
2224
services: [
2325
LoginService.create(),
26+
AhaService.create(),
2427
],
2528
);
2629

2730
void dispose() => _client.dispose();
2831

2932
LoginService get login => _client.getService();
33+
34+
AhaService get aha => _client.getService();
3035
}

lib/src/api/aha_service.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import 'package:chopper/chopper.dart';
2+
3+
part 'aha_service.chopper.dart';
4+
5+
@ChopperApi(baseUrl: '/webservices/homeautoswitch.lua')
6+
abstract class AhaService extends ChopperService {
7+
static AhaService create([ChopperClient? client]) => _$AhaService(client);
8+
9+
@Get()
10+
Future<Response> get(
11+
@Query('switchcmd') String switchCmd,
12+
);
13+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import 'package:freezed_annotation/freezed_annotation.dart';
2+
3+
part 'authentication_exception.freezed.dart';
4+
5+
@freezed
6+
class AuthenticationException
7+
with _$AuthenticationException
8+
implements Exception {
9+
factory AuthenticationException.invalidStatus(
10+
int statusCode, [
11+
Object? error,
12+
]) = _Status;
13+
14+
factory AuthenticationException.invalidChallangeFormat() =
15+
_InvalidChallangeFormat;
16+
17+
factory AuthenticationException.invalidCredentials() = _InvalidCredentials;
18+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import 'dart:async';
2+
import 'dart:convert';
3+
4+
import 'package:chopper/chopper.dart';
5+
import 'package:convert/convert.dart';
6+
import 'package:cryptography/cryptography.dart';
7+
import 'package:meta/meta.dart';
8+
9+
import 'authentication_exception.dart';
10+
import 'login_service.dart';
11+
import 'models/right.dart';
12+
import 'models/session_info.dart';
13+
import 'models/user.dart';
14+
import 'user_credentials.dart';
15+
16+
// ignore: must_be_immutable
17+
abstract class LoginManager implements RequestInterceptor, Authenticator {
18+
static const _pbkd2Bits = 256;
19+
static final _challangeRegexp = RegExp(
20+
r'^2\$(\d+)\$([0-9a-f]+)\$(\d+)\$([0-9a-f]+)$',
21+
caseSensitive: false,
22+
);
23+
24+
var _sid = SessionInfo.invalidSid;
25+
var _rights = const <Right>[];
26+
27+
late final ChopperClient _client;
28+
29+
List<Right> get rights => _rights;
30+
31+
// ignore: use_setters_to_change_properties
32+
@internal
33+
void linkToClient(ChopperClient client) {
34+
_client = client;
35+
}
36+
37+
@override
38+
FutureOr<Request> onRequest(Request request) async {
39+
if (request.isLogin) {
40+
return request;
41+
}
42+
43+
if (_sid == SessionInfo.invalidSid) {
44+
await _performLogin();
45+
}
46+
47+
return _copyRequestWithSid(request);
48+
}
49+
50+
@override
51+
FutureOr<Request?> authenticate(Request request, Response response) async {
52+
if (response.statusCode != 403 || request.isLogin) {
53+
return null;
54+
}
55+
56+
await _performLogin();
57+
return _copyRequestWithSid(request);
58+
}
59+
60+
@protected
61+
FutureOr<UserCredentials> obtainCredentials(List<User> knownUsers);
62+
63+
Request _copyRequestWithSid(Request request) => request.copyWith(parameters: {
64+
...request.parameters,
65+
'sid': _sid,
66+
});
67+
68+
Future<void> _performLogin() async {
69+
final loginService = _client.getService<LoginService>();
70+
71+
final sessionInfo = _extractSessionInfo(
72+
_sid == SessionInfo.invalidSid
73+
? await loginService.getLoginStatus()
74+
: await loginService.checkSessionValid(sid: _sid),
75+
);
76+
77+
final blockTimeout = Future.delayed(
78+
Duration(seconds: sessionInfo.blockTime),
79+
);
80+
81+
final credentials = await obtainCredentials(sessionInfo.users);
82+
83+
final response = await _solveVersion2Challange(sessionInfo, credentials);
84+
85+
await blockTimeout;
86+
final loginResult = _extractSessionInfo(
87+
await loginService.login(
88+
username: credentials.username,
89+
response: response,
90+
),
91+
);
92+
93+
if (loginResult.sid == SessionInfo.invalidSid) {
94+
throw AuthenticationException.invalidCredentials();
95+
}
96+
97+
_sid = loginResult.sid;
98+
_rights = loginResult.rights;
99+
}
100+
101+
Future<String> _solveVersion2Challange(
102+
SessionInfo sessionInfo,
103+
UserCredentials credentials,
104+
) async {
105+
final challangeMatch = _challangeRegexp.firstMatch(sessionInfo.challange);
106+
if (challangeMatch == null) {
107+
throw AuthenticationException.invalidChallangeFormat();
108+
}
109+
110+
final iter1 = int.parse(challangeMatch[1]!);
111+
final salt1 = hex.decode(challangeMatch[2]!);
112+
final iter2 = int.parse(challangeMatch[3]!);
113+
final salt2 = hex.decode(challangeMatch[4]!);
114+
115+
final pbkdfRound1 = Pbkdf2(
116+
macAlgorithm: Hmac.sha256(),
117+
iterations: iter1,
118+
bits: _pbkd2Bits,
119+
);
120+
final pbkdfRound2 = Pbkdf2(
121+
macAlgorithm: Hmac.sha256(),
122+
iterations: iter2,
123+
bits: _pbkd2Bits,
124+
);
125+
126+
final hash1 = await pbkdfRound1.deriveKey(
127+
secretKey: SecretKey(utf8.encode(credentials.password)),
128+
nonce: salt1,
129+
);
130+
final hash2 = await pbkdfRound2.deriveKey(
131+
secretKey: hash1,
132+
nonce: salt2,
133+
);
134+
135+
return '${hex.encode(salt2)}\$${hex.encode(await hash2.extractBytes())}';
136+
}
137+
138+
SessionInfo _extractSessionInfo(Response<SessionInfo> response) {
139+
if (!response.isSuccessful || response.body == null) {
140+
throw AuthenticationException.invalidStatus(
141+
response.statusCode,
142+
response.error ?? response.bodyString,
143+
);
144+
}
145+
146+
return response.body!;
147+
}
148+
}
149+
150+
extension _RequestX on Request {
151+
bool get isLogin => url.contains('/login_sid.lua');
152+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import 'package:chopper/chopper.dart';
2+
3+
import 'models/session_info.dart';
4+
5+
part 'login_service.chopper.dart';
6+
7+
@ChopperApi(baseUrl: '/login_sid.lua?version=2')
8+
abstract class LoginService extends ChopperService {
9+
static LoginService create([ChopperClient? client]) => _$LoginService(client);
10+
11+
@Get()
12+
Future<Response<SessionInfo>> getLoginStatus();
13+
14+
@Post()
15+
@FactoryConverter(
16+
request: FormUrlEncodedConverter.requestFactory,
17+
)
18+
Future<Response<SessionInfo>> login({
19+
@Field() required String username,
20+
@Field() required String response,
21+
});
22+
23+
@Post()
24+
@FactoryConverter(
25+
request: FormUrlEncodedConverter.requestFactory,
26+
)
27+
Future<Response<SessionInfo>> checkSessionValid({
28+
@Field() required String sid,
29+
});
30+
31+
@Post()
32+
@FactoryConverter(
33+
request: FormUrlEncodedConverter.requestFactory,
34+
)
35+
Future<Response<SessionInfo>> logout({
36+
@Field() required String sid,
37+
@Field() bool logout = true,
38+
});
39+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import 'package:freezed_annotation/freezed_annotation.dart';
2+
import 'package:xml/xml.dart';
3+
4+
part 'right.freezed.dart';
5+
6+
enum AccessLevel {
7+
none,
8+
read,
9+
write,
10+
}
11+
12+
@freezed
13+
class Right with _$Right {
14+
const Right._();
15+
16+
const factory Right.nas(AccessLevel access) = _Nas;
17+
const factory Right.app(AccessLevel access) = _App;
18+
const factory Right.homeAuto(AccessLevel access) = _HomeAuto;
19+
const factory Right.boxAdmin(AccessLevel access) = _BoxAdmin;
20+
const factory Right.phone(AccessLevel access) = _Phone;
21+
22+
const factory Right.unknown({
23+
required String name,
24+
required AccessLevel access,
25+
}) = _Right;
26+
27+
factory Right.fromXml(XmlElement nameElement, XmlElement accessElement) {
28+
assert(nameElement.name.toString() == 'Name');
29+
assert(accessElement.name.toString() == 'Access');
30+
31+
final access = AccessLevel.values[int.parse(accessElement.text)];
32+
33+
switch (nameElement.text) {
34+
case 'NAS':
35+
return Right.nas(access);
36+
case 'App':
37+
return Right.app(access);
38+
case 'HomeAuto':
39+
return Right.homeAuto(access);
40+
case 'BoxAdmin':
41+
return Right.boxAdmin(access);
42+
case 'Phone':
43+
return Right.phone(access);
44+
default:
45+
return Right.unknown(
46+
name: nameElement.text,
47+
access: access,
48+
);
49+
}
50+
}
51+
52+
String get name => when(
53+
nas: (_) => 'NAS',
54+
app: (_) => 'App',
55+
homeAuto: (_) => 'HomeAuto',
56+
boxAdmin: (_) => 'BoxAdmin',
57+
phone: (_) => 'Phone',
58+
unknown: (name, _) => name,
59+
);
60+
61+
void toXml(XmlBuilder builder) => builder
62+
..element('Name', nest: name)
63+
..element('Access', nest: access.index);
64+
}

0 commit comments

Comments
 (0)